mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
wip: checkpoint v0.8.67 fallback candidate
This commit is contained in:
1 parent
b6a53c9891
commit
000c041894
17 files changed
+473
-629
No files matched your search
@@ -132,3 +132,9 @@ notes/
|
||||
# Agent-local roadmap and scratch (never commit — may hold unreleased plans)
|
||||
.agents/
|
||||
scratchpad/
|
||||
|
||||
# Exploratory operations spec / prompt — never track (canonical copy lives at CW root)
|
||||
operations9.md
|
||||
OPERATIONS_0_9_0.md
|
||||
operations_0_9_0.md
|
||||
CODEWHALE_0_9_0_CUTOVER.md
|
||||
Generated
+19
-1
@@ -1021,6 +1021,7 @@ dependencies = [
|
||||
"image",
|
||||
"libc",
|
||||
"lru 0.18.0",
|
||||
"mimalloc",
|
||||
"multimap",
|
||||
"oauth2",
|
||||
"objc2",
|
||||
@@ -3009,6 +3010,15 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.12"
|
||||
@@ -3254,6 +3264,15 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -5774,7 +5793,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
|
||||
+8
-1
@@ -52,7 +52,7 @@ serde_json = "1.0.149"
|
||||
semver = "1.0.28"
|
||||
thiserror = "2.0"
|
||||
tempfile = "3.27"
|
||||
tokio = { version = "1.50.0", features = ["full"] }
|
||||
tokio = { version = "1.50.0", features = ["fs", "io-util", "io-std", "macros", "net", "process", "rt", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
toml = "1.0.6"
|
||||
toml_edit = "0.25.12"
|
||||
sha2 = "0.11"
|
||||
@@ -61,3 +61,10 @@ tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
mimalloc = { version = "0.1", default-features = false }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
strip = true
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
@@ -80,6 +80,7 @@ tar = "0.4"
|
||||
flate2 = "1.1"
|
||||
sha2.workspace = true
|
||||
rust-i18n = "4.1.0"
|
||||
mimalloc.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
codewhale-build-support = { path = "../build-support", version = "0.8.67" }
|
||||
|
||||
+20
-10
@@ -189,7 +189,7 @@ const ALLOW_INSECURE_HTTP_ENV: &str = "DEEPSEEK_ALLOW_INSECURE_HTTP";
|
||||
/// of committing to the full remaining window up front.
|
||||
const RATE_LIMIT_PAUSE_RECHECK_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
pub(super) const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 8 * 1024 * 1024; // 8 MB
|
||||
pub(super) const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 1024 * 1024; // 1 MB
|
||||
pub(super) const SSE_BACKPRESSURE_SLEEP_MS: u64 = 10;
|
||||
pub(super) const SSE_MAX_LINES_PER_CHUNK: usize = 256;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1110,9 +1110,7 @@ impl DeepSeekClient {
|
||||
});
|
||||
apply_reasoning_effort(&mut body, Some("off"), self.api_provider);
|
||||
|
||||
let response = self
|
||||
.send_with_retry(|| self.http_client.post(&url).json(&body))
|
||||
.await?;
|
||||
let response = self.send_json_with_retry(&url, &body).await?;
|
||||
|
||||
let value: serde_json::Value = response.json().await?;
|
||||
let translated = value["choices"][0]["message"]["content"]
|
||||
@@ -1331,9 +1329,7 @@ impl DeepSeekClient {
|
||||
let body = build_speech_synthesis_body(&model, &text, instruction, audio);
|
||||
|
||||
let url = api_url(&self.base_url, "chat/completions");
|
||||
let response = self
|
||||
.send_with_retry(|| self.http_client.post(&url).json(&body))
|
||||
.await?;
|
||||
let response = self.send_json_with_retry(&url, &body).await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
|
||||
@@ -1506,6 +1502,22 @@ impl DeepSeekClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn send_json_with_retry(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<reqwest::Response> {
|
||||
let request_body =
|
||||
serde_json::to_vec(body).context("Failed to serialize JSON request body")?;
|
||||
self.send_with_retry(|| {
|
||||
self.http_client
|
||||
.post(url)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(request_body.clone())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate the structured `LlmError` into both a categorical label
|
||||
@@ -1991,9 +2003,7 @@ impl DeepSeekClient {
|
||||
"suffix": suffix,
|
||||
"max_tokens": max_tokens,
|
||||
});
|
||||
let response = self
|
||||
.send_with_retry(|| self.http_client.post(&url).json(&body))
|
||||
.await?;
|
||||
let response = self.send_json_with_retry(&url, &body).await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
|
||||
|
||||
@@ -224,11 +224,8 @@ impl DeepSeekClient {
|
||||
self.path_suffix.as_deref(),
|
||||
);
|
||||
let open_timeout = stream_open_timeout();
|
||||
let response = match tokio_timeout(
|
||||
open_timeout,
|
||||
self.send_with_retry(|| self.http_client.post(&url).json(&body)),
|
||||
)
|
||||
.await
|
||||
let response = match tokio_timeout(open_timeout, self.send_json_with_retry(&url, &body))
|
||||
.await
|
||||
{
|
||||
Ok(result) => result?,
|
||||
Err(_elapsed) => {
|
||||
@@ -350,9 +347,7 @@ impl DeepSeekClient {
|
||||
"chat/completions",
|
||||
self.path_suffix.as_deref(),
|
||||
);
|
||||
let response = self
|
||||
.send_with_retry(|| self.http_client.post(&url).json(&body))
|
||||
.await?;
|
||||
let response = self.send_json_with_retry(&url, &body).await?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
|
||||
@@ -91,6 +91,8 @@ impl DeepSeekClient {
|
||||
// ChatGPT backend additionally requires the account id and the
|
||||
// experimental Responses beta opt-in.
|
||||
let account_id = crate::oauth::codex_account_id();
|
||||
let request_body =
|
||||
serde_json::to_vec(&body).context("Failed to serialize Responses API request body")?;
|
||||
let response = self
|
||||
.send_with_retry(|| {
|
||||
let mut builder = self
|
||||
@@ -103,7 +105,7 @@ impl DeepSeekClient {
|
||||
if let Some(account_id) = &account_id {
|
||||
builder = builder.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
builder.json(&body)
|
||||
builder.body(request_body.clone())
|
||||
})
|
||||
.await
|
||||
.context("Responses API request failed")?;
|
||||
|
||||
@@ -74,6 +74,8 @@ const LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS: usize = 120_000;
|
||||
const LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS: usize = 72_000;
|
||||
const LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS: usize = 36_000;
|
||||
const TOOL_PRUNE_STOP_CHECK_BYTES: usize = 16 * 1024;
|
||||
const RETAINED_TOOL_RESULT_MAX_CHARS: usize = 64 * 1024;
|
||||
const RETAINED_THINKING_MAX_CHARS: usize = 16 * 1024;
|
||||
const LARGE_CONTEXT_SUMMARY_MAX_TOKENS: u32 = 2_048;
|
||||
const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000;
|
||||
const CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT: usize = 85;
|
||||
@@ -852,6 +854,61 @@ where
|
||||
bytes_saved
|
||||
}
|
||||
|
||||
fn truncate_retained_block(label: &str, content: &mut String, max_chars: usize) -> bool {
|
||||
let char_count = content.chars().count();
|
||||
if char_count <= max_chars {
|
||||
return false;
|
||||
}
|
||||
|
||||
let snippet_budget = max_chars.saturating_sub(256).max(1024);
|
||||
let head_chars = snippet_budget / 2;
|
||||
let tail_chars_budget = snippet_budget.saturating_sub(head_chars);
|
||||
let head = truncate_chars(content, head_chars).to_string();
|
||||
let tail = tail_chars(content, tail_chars_budget);
|
||||
*content =
|
||||
format!("[{label} retained-history truncated from {char_count} chars]\n{head}\n…\n{tail}");
|
||||
true
|
||||
}
|
||||
|
||||
fn sanitize_retained_messages(mut messages: Vec<Message>) -> Vec<Message> {
|
||||
for message in &mut messages {
|
||||
for block in &mut message.content {
|
||||
match block {
|
||||
ContentBlock::ToolResult {
|
||||
content,
|
||||
content_blocks,
|
||||
..
|
||||
} => {
|
||||
if truncate_retained_block(
|
||||
"tool result",
|
||||
content,
|
||||
RETAINED_TOOL_RESULT_MAX_CHARS,
|
||||
) {
|
||||
*content_blocks = None;
|
||||
}
|
||||
}
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
// Signed thinking must stay byte-for-byte valid for providers that
|
||||
// verify replay signatures. Unsigned thinking is local memory pressure
|
||||
// and can be capped once compaction has summarized the old turn.
|
||||
if signature.is_none() {
|
||||
truncate_retained_block(
|
||||
"thinking block",
|
||||
thinking,
|
||||
RETAINED_THINKING_MAX_CHARS,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
/// Result of a compaction operation with metadata.
|
||||
#[derive(Debug)]
|
||||
pub struct CompactionResult {
|
||||
@@ -951,7 +1008,7 @@ pub async fn compact_messages_safe(
|
||||
));
|
||||
if was_over_threshold && now_under_threshold {
|
||||
return Ok(CompactionResult {
|
||||
messages: pruned_messages,
|
||||
messages: sanitize_retained_messages(pruned_messages),
|
||||
summary_prompt: None,
|
||||
removed_messages: Vec::new(),
|
||||
retries_used: 0,
|
||||
@@ -982,10 +1039,11 @@ pub async fn compact_messages_safe(
|
||||
.await
|
||||
{
|
||||
Ok((msgs, prompt, removed)) => {
|
||||
drop(removed);
|
||||
return Ok(CompactionResult {
|
||||
messages: msgs,
|
||||
messages: sanitize_retained_messages(msgs),
|
||||
summary_prompt: prompt,
|
||||
removed_messages: removed,
|
||||
removed_messages: Vec::new(),
|
||||
retries_used: attempt,
|
||||
});
|
||||
}
|
||||
@@ -1081,6 +1139,7 @@ pub async fn compact_messages(
|
||||
|
||||
// Extract workflow context (files touched, tasks in progress, etc.)
|
||||
let workflow_context = extract_workflow_context(&to_summarize, workspace);
|
||||
drop(to_summarize);
|
||||
|
||||
let anchors_section = anchor_summary_section(workspace);
|
||||
|
||||
@@ -1118,9 +1177,9 @@ pub async fn compact_messages(
|
||||
.collect();
|
||||
|
||||
Ok((
|
||||
pinned_messages,
|
||||
sanitize_retained_messages(pinned_messages),
|
||||
Some(SystemPrompt::Blocks(vec![summary_block])),
|
||||
to_summarize,
|
||||
Vec::new(),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,18 @@
|
||||
//! `config.rs` with a private `use`, so no new external surface is created
|
||||
//! (#3311).
|
||||
|
||||
pub const DEFAULT_MAX_SUBAGENTS: usize = 20;
|
||||
pub const MAX_SUBAGENTS: usize = 20;
|
||||
/// Temporary high-throughput default while the shared-context cutover makes
|
||||
/// agent fanout cheap. This should eventually be governed by API/backpressure
|
||||
/// budgets rather than memory-driven count throttles.
|
||||
pub const DEFAULT_MAX_SUBAGENTS: usize = 64;
|
||||
/// User-configurable ceiling for concurrent sub-agent execution. Keep this
|
||||
/// above the default so operators can opt into larger API-bound fanout without
|
||||
/// code changes while the full resource budget gate lands.
|
||||
pub const MAX_SUBAGENTS: usize = 128;
|
||||
/// Upper bound for queued + running sub-agent admissions. This is deliberately
|
||||
/// higher than the instantaneous concurrency cap so Workflow-style fanout can
|
||||
/// opt into large bounded populations without unbounded queue growth.
|
||||
pub const MAX_SUBAGENT_ADMISSION: usize = 200;
|
||||
pub const MAX_SUBAGENT_ADMISSION: usize = 1024;
|
||||
/// Default per-step DeepSeek API timeout for sub-agent requests, in seconds.
|
||||
/// Matches the legacy hardcoded value so existing configs keep their old
|
||||
/// behavior when `[subagents] api_timeout_secs` is unset (#1806, #1808).
|
||||
|
||||
@@ -2802,7 +2802,7 @@ impl Engine {
|
||||
Ok(result) => {
|
||||
if !result.messages.is_empty() || self.session.messages.is_empty() {
|
||||
let messages_after = result.messages.len();
|
||||
self.session.messages = result.messages.into();
|
||||
self.session.replace_messages(result.messages);
|
||||
self.merge_compaction_summary(result.summary_prompt);
|
||||
self.emit_session_updated().await;
|
||||
let removed = messages_before.saturating_sub(messages_after);
|
||||
@@ -2898,7 +2898,7 @@ impl Engine {
|
||||
{
|
||||
Ok(result) => {
|
||||
let messages_after = result.messages.len();
|
||||
self.session.messages = result.messages.into();
|
||||
self.session.replace_messages(result.messages);
|
||||
self.emit_session_updated().await;
|
||||
|
||||
let summary = format!(
|
||||
@@ -3024,7 +3024,7 @@ impl Engine {
|
||||
}
|
||||
|
||||
if !compacted_messages.is_empty() || self.session.messages.is_empty() {
|
||||
self.session.messages = compacted_messages.into();
|
||||
self.session.replace_messages(compacted_messages);
|
||||
}
|
||||
self.merge_compaction_summary(summary_prompt);
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ impl Engine {
|
||||
// Only update if we got valid messages (never corrupt state)
|
||||
if !result.messages.is_empty() || self.session.messages.is_empty() {
|
||||
let auto_messages_after = result.messages.len();
|
||||
self.session.messages = result.messages.into();
|
||||
self.session.replace_messages(result.messages);
|
||||
self.merge_compaction_summary(result.summary_prompt);
|
||||
self.emit_session_updated().await;
|
||||
let removed = auto_messages_before.saturating_sub(auto_messages_after);
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
#![allow(clippy::uninlined_format_args)]
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
use std::io::{self, IsTerminal, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
@@ -14,27 +14,45 @@
|
||||
//! deterministic for replay.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::env;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use rquickjs::function::{Async, Func};
|
||||
use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, CaughtError, Ctx, Promise, Value};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::{oneshot, watch};
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, oneshot, watch};
|
||||
|
||||
use crate::driver::{ProgressEvent, TaskCompletion, TaskRequest, WorkflowDriver};
|
||||
use crate::error::WhaleflowJsError;
|
||||
use crate::schema::{compile_schema, decode_reply};
|
||||
use crate::{PARALLEL_MAX_ITEMS, WHALEFLOW_LIFETIME_CAP, normalize_profile};
|
||||
|
||||
const DEFAULT_VM_MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
|
||||
const MIN_VM_MEMORY_LIMIT_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_VM_MEMORY_LIMIT_BYTES: usize = 512 * 1024 * 1024;
|
||||
const DEFAULT_VM_STACK_BYTES: usize = 1024 * 1024;
|
||||
const MIN_VM_STACK_BYTES: usize = 128 * 1024;
|
||||
const MAX_VM_STACK_BYTES: usize = 8 * 1024 * 1024;
|
||||
const DEFAULT_VM_THREAD_STACK_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MIN_VM_THREAD_STACK_BYTES: usize = 512 * 1024;
|
||||
const MAX_VM_THREAD_STACK_BYTES: usize = 16 * 1024 * 1024;
|
||||
const DEFAULT_MAX_CONCURRENT_VMS: usize = 4;
|
||||
const MAX_CONCURRENT_VMS: usize = 256;
|
||||
|
||||
const VM_MEMORY_LIMIT_MB_ENV: &str = "CODEWHALE_WHALEFLOW_JS_MEMORY_LIMIT_MB";
|
||||
const VM_STACK_KB_ENV: &str = "CODEWHALE_WHALEFLOW_JS_STACK_KB";
|
||||
const VM_THREAD_STACK_KB_ENV: &str = "CODEWHALE_WHALEFLOW_JS_THREAD_STACK_KB";
|
||||
const VM_MAX_CONCURRENT_ENV: &str = "CODEWHALE_WHALEFLOW_JS_MAX_CONCURRENT";
|
||||
|
||||
/// Resource limits applied to the QuickJS runtime before any script runs.
|
||||
///
|
||||
/// There is deliberately no wall-clock timeout here: cancellation (dropping
|
||||
/// the run future, or the driver's cancel cascade) is the deadline mechanism.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct VmLimits {
|
||||
/// QuickJS heap ceiling in bytes (default 64 MiB).
|
||||
/// QuickJS heap ceiling in bytes (default 32 MiB).
|
||||
pub memory_limit_bytes: usize,
|
||||
/// Maximum interpreter stack in bytes (default 1 MiB).
|
||||
pub max_stack_bytes: usize,
|
||||
@@ -42,13 +60,63 @@ pub struct VmLimits {
|
||||
|
||||
impl Default for VmLimits {
|
||||
fn default() -> Self {
|
||||
Self::from_env()
|
||||
}
|
||||
}
|
||||
|
||||
impl VmLimits {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
memory_limit_bytes: 64 * 1024 * 1024,
|
||||
max_stack_bytes: 1024 * 1024,
|
||||
memory_limit_bytes: env_usize_bytes(
|
||||
VM_MEMORY_LIMIT_MB_ENV,
|
||||
1024 * 1024,
|
||||
MIN_VM_MEMORY_LIMIT_BYTES,
|
||||
MAX_VM_MEMORY_LIMIT_BYTES,
|
||||
DEFAULT_VM_MEMORY_LIMIT_BYTES,
|
||||
),
|
||||
max_stack_bytes: env_usize_bytes(
|
||||
VM_STACK_KB_ENV,
|
||||
1024,
|
||||
MIN_VM_STACK_BYTES,
|
||||
MAX_VM_STACK_BYTES,
|
||||
DEFAULT_VM_STACK_BYTES,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env_usize_bytes(name: &str, unit: usize, min: usize, max: usize, default: usize) -> usize {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.and_then(|raw| raw.parse::<usize>().ok())
|
||||
.and_then(|value| value.checked_mul(unit))
|
||||
.map(|bytes| bytes.clamp(min, max))
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn max_concurrent_vms() -> usize {
|
||||
env::var(VM_MAX_CONCURRENT_ENV)
|
||||
.ok()
|
||||
.and_then(|raw| raw.parse::<usize>().ok())
|
||||
.map(|value| value.clamp(1, MAX_CONCURRENT_VMS))
|
||||
.unwrap_or(DEFAULT_MAX_CONCURRENT_VMS)
|
||||
}
|
||||
|
||||
fn vm_thread_stack_bytes() -> usize {
|
||||
env_usize_bytes(
|
||||
VM_THREAD_STACK_KB_ENV,
|
||||
1024,
|
||||
MIN_VM_THREAD_STACK_BYTES,
|
||||
MAX_VM_THREAD_STACK_BYTES,
|
||||
DEFAULT_VM_THREAD_STACK_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
fn vm_admission() -> &'static Arc<Semaphore> {
|
||||
static ADMISSION: OnceLock<Arc<Semaphore>> = OnceLock::new();
|
||||
ADMISSION.get_or_init(|| Arc::new(Semaphore::new(max_concurrent_vms())))
|
||||
}
|
||||
|
||||
/// Executes WhaleFlow scripts, one isolated QuickJS runtime per run.
|
||||
///
|
||||
/// Every [`WhaleflowVm::run_script`] call spins up a fresh interpreter on a
|
||||
@@ -101,13 +169,20 @@ impl WhaleflowVm {
|
||||
armed: true,
|
||||
};
|
||||
|
||||
let permit = vm_admission()
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| WhaleflowJsError::VmInit("VM admission gate closed".to_string()))?;
|
||||
let limits = self.limits;
|
||||
let source = source.to_string();
|
||||
let thread_driver = driver.clone();
|
||||
let thread_cancel = cancel.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("whaleflow-js-vm".to_string())
|
||||
.stack_size(vm_thread_stack_bytes())
|
||||
.spawn(move || {
|
||||
let _permit: OwnedSemaphorePermit = permit;
|
||||
let outcome = vm_thread_main(
|
||||
source,
|
||||
args_json,
|
||||
@@ -231,9 +306,12 @@ async fn run_in_vm(
|
||||
.await
|
||||
.map_err(|err| WhaleflowJsError::VmInit(err.to_string()))?;
|
||||
|
||||
context
|
||||
let result = context
|
||||
.async_with(async |ctx| run_in_ctx(ctx, source, args_json, driver, cancel).await)
|
||||
.await
|
||||
.await;
|
||||
drop(context);
|
||||
runtime.run_gc().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_in_ctx(
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
# RFC: Hook Lifecycle Data Flow
|
||||
|
||||
**Issue:** #1364
|
||||
**Status:** Draft
|
||||
**Date:** 2026-05-28
|
||||
|
||||
## 1. Problem
|
||||
|
||||
CodeWhale already has lifecycle hooks and MCP support, but the current hook
|
||||
surface is mostly observer-only. This blocks portable extensions that need to
|
||||
participate in the agent data flow:
|
||||
|
||||
- memory/context injection before a user message reaches the model
|
||||
- post-turn background analysis that prepares context for the next turn
|
||||
- sub-agent lifecycle visibility for orchestration and audit extensions
|
||||
|
||||
The current `message_submit` event fires before dispatch, but its output is
|
||||
ignored. `TurnComplete`, `AgentSpawned`, and `AgentComplete` exist internally,
|
||||
but they are not exposed as configurable hook events.
|
||||
|
||||
## 2. PR split
|
||||
|
||||
This issue should be implemented as three PRs. Each PR should be independently
|
||||
reviewable and should leave the hook system in a useful state.
|
||||
|
||||
### PR 1: Mutable `message_submit`
|
||||
|
||||
Add a structured hook execution path for `message_submit` that can transform or
|
||||
block the user's submitted text before it is sent to the engine.
|
||||
|
||||
Scope:
|
||||
|
||||
- keep the existing `[[hooks.hooks]]` config shape
|
||||
- pass a JSON payload to the hook on stdin
|
||||
- interpret stdout JSON containing `text` as the replacement user text
|
||||
- treat exit code `2` as an intentional block
|
||||
- run multiple submit hooks serially in config order
|
||||
- keep existing env vars for compatibility
|
||||
- keep `shell_env` stdout parsing unchanged
|
||||
|
||||
Non-goals:
|
||||
|
||||
- no tool argument mutation
|
||||
- no global stdout JSON semantics for all hook events
|
||||
- no transcript or model response mutation
|
||||
|
||||
### PR 2: `turn_end`
|
||||
|
||||
Expose the existing turn completion lifecycle as a hook event.
|
||||
|
||||
Scope:
|
||||
|
||||
- add `HookEvent::TurnEnd` with event name `turn_end`
|
||||
- fire from the UI's `EngineEvent::TurnComplete` branch after core app state,
|
||||
usage, cost, notifications, and receipt state have been updated
|
||||
- pass turn metadata on stdin as JSON
|
||||
- make failures non-blocking and warn-only
|
||||
- include a `stop_hook_active` field in the payload, initially `false`, so the
|
||||
contract can support re-entry protection later
|
||||
|
||||
Non-goals:
|
||||
|
||||
- no change to turn status
|
||||
- no blocking of user input
|
||||
- no transcript mutation from `turn_end`
|
||||
|
||||
Implementation note for the v0.9 branch: the narrow #2578 harvest uses the
|
||||
shared structured observer path introduced for sub-agent lifecycle hooks. It
|
||||
fires before queued follow-up dispatch, after queue-recovery state is known, so
|
||||
the payload can report the queued-message count without letting a hook change
|
||||
what gets sent next. Stdout is ignored for `turn_end`; only `message_submit`
|
||||
has a stdout mutation contract.
|
||||
|
||||
### PR 3: Subagent lifecycle observer hooks
|
||||
|
||||
Expose subagent start and completion as observer-only hook events.
|
||||
|
||||
Scope:
|
||||
|
||||
- add `HookEvent::SubagentSpawn` with event name `subagent_spawn`
|
||||
- add `HookEvent::SubagentComplete` with event name `subagent_complete`
|
||||
- fire from the existing `AgentSpawned` and `AgentComplete` UI branches
|
||||
- pass subagent metadata on stdin as JSON
|
||||
- make failures non-blocking and warn-only
|
||||
|
||||
Non-goals:
|
||||
|
||||
- no subagent spawn gating in the first version
|
||||
- no subagent prompt/result mutation
|
||||
- no changes to subagent scheduling
|
||||
|
||||
## 3. PR 1 detailed plan
|
||||
|
||||
### 3.1 Contract
|
||||
|
||||
Configuration:
|
||||
|
||||
```toml
|
||||
[[hooks.hooks]]
|
||||
event = "message_submit"
|
||||
command = "~/.deepseek/hooks/inject-memory.sh"
|
||||
timeout_secs = 2
|
||||
continue_on_error = true
|
||||
```
|
||||
|
||||
Input payload on stdin:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "message_submit",
|
||||
"text": "original user text",
|
||||
"session_id": "sess_xxxx",
|
||||
"workspace": "/path/to/workspace",
|
||||
"mode": "agent",
|
||||
"model": "deepseek-chat",
|
||||
"total_tokens": 1234
|
||||
}
|
||||
```
|
||||
|
||||
Output payload on stdout:
|
||||
|
||||
```json
|
||||
{ "text": "replacement user text" }
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- exit `0` with stdout JSON containing `text: string` replaces the current text
|
||||
- exit `0` with empty stdout leaves the current text unchanged
|
||||
- exit `0` with JSON that does not contain `text` leaves the current text
|
||||
unchanged
|
||||
- exit `2` blocks submission before the message is appended to history or sent
|
||||
to the engine
|
||||
- other non-zero exits follow `continue_on_error`
|
||||
- `true`: warn, keep the current text, continue later hooks
|
||||
- `false`: stop later hooks and block submission with an error message
|
||||
- `background = true` on `message_submit` remains observer-only and cannot
|
||||
transform or block submission
|
||||
|
||||
Multiple hooks:
|
||||
|
||||
- hooks run in config order
|
||||
- each hook receives the latest transformed text
|
||||
- the final transformed text is the only text used by file mention expansion,
|
||||
skill wrapping, auto routing, history, and `api_messages`
|
||||
|
||||
### 3.2 Implementation steps
|
||||
|
||||
1. Add structured submit outcome types in `crates/tui/src/hooks.rs`:
|
||||
|
||||
```rust
|
||||
pub enum MessageSubmitOutcome {
|
||||
Unchanged,
|
||||
Replaced(String),
|
||||
Blocked { reason: String },
|
||||
}
|
||||
```
|
||||
|
||||
2. Add a stdin-capable sync executor:
|
||||
|
||||
```rust
|
||||
fn execute_sync_with_stdin(
|
||||
&self,
|
||||
hook: &Hook,
|
||||
env_vars: &HashMap<String, String>,
|
||||
stdin_json: &serde_json::Value,
|
||||
) -> HookResult
|
||||
```
|
||||
|
||||
This should reuse the existing timeout, working directory, stdout, stderr, and
|
||||
error handling behavior from `execute_sync`.
|
||||
|
||||
3. Add a `message_submit` transform entrypoint:
|
||||
|
||||
```rust
|
||||
pub fn execute_message_submit_transform(
|
||||
&self,
|
||||
context: &HookContext,
|
||||
original_text: &str,
|
||||
) -> MessageSubmitOutcome
|
||||
```
|
||||
|
||||
This method should:
|
||||
|
||||
- filter configured `MessageSubmit` hooks through existing condition matching
|
||||
- build a JSON payload for each hook using the current text
|
||||
- run non-background hooks through `execute_sync_with_stdin`
|
||||
- run background hooks with the existing observer-only path
|
||||
- parse stdout JSON only for non-background hooks
|
||||
- return the final text or a block result
|
||||
|
||||
4. Apply the transformed message in `dispatch_user_message`:
|
||||
|
||||
- run the transform before `last_submitted_prompt`, file mentions, history, and
|
||||
`api_messages`
|
||||
- create a local mutable `QueuedMessage` or replacement display text
|
||||
- if blocked, show a status message or toast and return without dispatch
|
||||
|
||||
5. Update `/hooks events`:
|
||||
|
||||
- keep `message_submit` listed
|
||||
- update description to say it can transform or block user text
|
||||
|
||||
6. Update user-facing docs:
|
||||
|
||||
- document the stdin/stdout contract
|
||||
- document exit code `2`
|
||||
- document that `shell_env` still uses `KEY=VALUE` stdout
|
||||
|
||||
### 3.3 Test plan
|
||||
|
||||
Unit tests in `crates/tui/src/hooks.rs`:
|
||||
|
||||
- parses stdout `{"text":"changed"}` as replacement
|
||||
- empty stdout means unchanged
|
||||
- JSON without `text` means unchanged
|
||||
- malformed stdout means unchanged with warning semantics
|
||||
- exit `2` maps to blocked
|
||||
- multiple hooks apply transforms in order
|
||||
- background `message_submit` hook cannot transform
|
||||
- `continue_on_error = false` blocks on non-zero failure
|
||||
|
||||
TUI integration or focused dispatch tests:
|
||||
|
||||
- transformed text is written to `api_messages`
|
||||
- transformed text is written to visible history
|
||||
- transformed text is used by file mention expansion
|
||||
- blocked submit does not append user history
|
||||
- blocked submit does not push an API message
|
||||
- blocked submit leaves loading state false
|
||||
|
||||
Manual smoke test:
|
||||
|
||||
1. Add a config hook that prepends `[hooked] ` to every submitted message.
|
||||
2. Submit `hello`.
|
||||
3. Verify the transcript and model input use `[hooked] hello`.
|
||||
4. Replace the hook with one that exits `2`.
|
||||
5. Submit `hello`.
|
||||
6. Verify no turn starts and the TUI shows the block reason.
|
||||
|
||||
## 4. Shared payload conventions
|
||||
|
||||
All new structured hook payloads should include:
|
||||
|
||||
- `event`
|
||||
- `session_id`
|
||||
- `workspace`
|
||||
- `mode`
|
||||
- `model`
|
||||
|
||||
Event-specific payloads should add only fields that are stable and useful for
|
||||
extension authors. Avoid leaking secrets, full tool outputs, or unbounded
|
||||
transcript content in the first version.
|
||||
|
||||
## 5. Compatibility
|
||||
|
||||
- Existing hook config remains valid.
|
||||
- Existing observer-only hooks keep working.
|
||||
- Existing env vars remain available.
|
||||
- `shell_env` keeps its existing stdout `KEY=VALUE` contract.
|
||||
- Structured stdout is interpreted only by `message_submit` in PR 1. Structured
|
||||
observer hooks such as `turn_end`, `subagent_spawn`, and `subagent_complete`
|
||||
receive JSON on stdin, but their stdout is ignored by the caller.
|
||||
|
||||
## 6. Review checkpoints
|
||||
|
||||
PR 1 should be accepted only if:
|
||||
|
||||
- submit mutation is covered by tests
|
||||
- submit blocking is covered by tests
|
||||
- the unchanged path preserves current behavior
|
||||
- `shell_env` tests still prove the old stdout contract
|
||||
- the docs clearly mark `message_submit` as the only mutable hook
|
||||
|
||||
PR 2 should be accepted only if:
|
||||
|
||||
- `turn_end` fires after `TurnComplete` app state updates
|
||||
- failure is warn-only
|
||||
- payload contains status and usage
|
||||
|
||||
PR 3 should be accepted only if:
|
||||
|
||||
- subagent hooks are observer-only
|
||||
- failures do not affect subagent lifecycle
|
||||
- payloads do not include unbounded or secret data
|
||||
@@ -1,167 +0,0 @@
|
||||
# RFC: Provider Fallback Chain
|
||||
|
||||
**Issue:** #2574
|
||||
**Reporter:** @hsdbeebou
|
||||
**Design source:** #2581 by @idling11
|
||||
**Status:** Draft for the v0.9 provider-routing lane
|
||||
**Date:** 2026-06-04
|
||||
|
||||
## Problem
|
||||
|
||||
CodeWhale can store credentials and defaults for several providers, but a
|
||||
running session uses one active provider route at a time. When that provider
|
||||
hits a rate limit, temporary outage, or transport failure, the user must notice
|
||||
the failure, run `/provider`, choose another route, and resubmit the turn.
|
||||
|
||||
That manual switch is especially disruptive during long-running agentic work.
|
||||
A provider fallback chain can keep work moving, but it also changes billing
|
||||
source, model behavior, tool support, context-window limits, and vendor
|
||||
expectations. The design must make that switch explicit and capability-aware.
|
||||
|
||||
## Principles
|
||||
|
||||
- Fallback is opt-in. No provider switch happens unless the user configured a
|
||||
fallback chain.
|
||||
- Billing and vendor changes are visible in the transcript and status UI.
|
||||
- Normal retry policy runs before fallback.
|
||||
- Fallback is allowed only before assistant content or tool calls have started
|
||||
streaming for the failing request.
|
||||
- Fallback candidates must support the request shape for the current turn.
|
||||
- Authentication, authorization, malformed request, and model-not-found errors
|
||||
do not silently switch providers by default.
|
||||
|
||||
## Proposed Config Shape
|
||||
|
||||
Keep the existing root `provider = "..."` setting as the primary route. Add an
|
||||
ordered fallback list and a small policy section:
|
||||
|
||||
```toml
|
||||
provider = "nvidia-nim"
|
||||
fallback_providers = ["deepseek", "openrouter"]
|
||||
|
||||
[provider_fallback]
|
||||
enabled = true
|
||||
reset_on_new_session = true
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `fallback_providers` is ordered and contains provider IDs already accepted by
|
||||
the provider parser.
|
||||
- The primary provider is not repeated in the fallback list.
|
||||
- Duplicate fallback providers are rejected.
|
||||
- Missing credentials produce a startup warning and make that fallback entry
|
||||
inactive until credentials appear.
|
||||
- If `provider_fallback.enabled` is absent, the presence of a non-empty
|
||||
`fallback_providers` list enables fallback.
|
||||
|
||||
## Fallback Eligibility
|
||||
|
||||
| Failure | Fallback by default? | Notes |
|
||||
| --- | --- | --- |
|
||||
| HTTP 429 | Yes | Rate limit or quota exhaustion on the active route. |
|
||||
| HTTP 502, 503, 504 | Yes | Temporary upstream failure after normal retries. |
|
||||
| Connect timeout / DNS failure | Yes | Transport path failed before content streamed. |
|
||||
| HTTP 401 / 403 | No | Usually bad credentials or account permissions. |
|
||||
| HTTP 400 | No | Usually client request shape or model parameter issue. |
|
||||
| Model not found | No | Avoid silently switching model families unless a future policy explicitly opts in. |
|
||||
| Stream interrupted after content | No | The transcript may already contain partial assistant content or tool-call deltas. |
|
||||
|
||||
The first implementation should classify errors centrally and expose tests for
|
||||
each case before any fallback execution is wired into the turn loop.
|
||||
|
||||
## Capability Gate
|
||||
|
||||
Before switching to a fallback provider/model, CodeWhale checks that the
|
||||
candidate can support the current request shape:
|
||||
|
||||
| Requirement | Gate |
|
||||
| --- | --- |
|
||||
| Tool calls | Candidate provider/model must support tool calling. |
|
||||
| Reasoning effort | Candidate must support the requested thinking mode, or the switch is blocked. |
|
||||
| Context size | Candidate context window must fit the estimated current request. |
|
||||
| Image inputs | Candidate must support vision if the turn includes images. |
|
||||
| Provider-specific headers | Candidate request must be rebuilt from that provider's own auth/base-url/header rules. |
|
||||
|
||||
If no fallback candidate passes the gate, CodeWhale surfaces the original
|
||||
provider error with a clear "fallback chain exhausted or incompatible" note.
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
1. Build the request for the active provider.
|
||||
2. Run existing retry policy for that provider.
|
||||
3. If retries exhaust with a fallback-eligible failure and no assistant content
|
||||
has streamed, evaluate the next fallback provider.
|
||||
4. Rebuild the request with the fallback provider's model, base URL, auth, and
|
||||
provider-specific headers.
|
||||
5. Add a visible transcript marker and status event before the fallback request
|
||||
starts.
|
||||
6. Continue through the chain until a provider succeeds, the chain is
|
||||
exhausted, or a non-eligible failure occurs.
|
||||
|
||||
Suggested transcript marker:
|
||||
|
||||
```text
|
||||
[provider fallback: nvidia-nim -> deepseek, reason: rate_limit]
|
||||
```
|
||||
|
||||
Suggested status text:
|
||||
|
||||
```text
|
||||
NVIDIA NIM unavailable; switched to DeepSeek fallback
|
||||
```
|
||||
|
||||
For multi-request turns, such as tool-call result follow-ups, fallback can be
|
||||
considered for a later request only if that later request has not started
|
||||
streaming assistant content yet. The transcript marker must identify that the
|
||||
turn changed provider between requests.
|
||||
|
||||
## UI and Commands
|
||||
|
||||
- `/provider` should show the primary route and the current fallback position.
|
||||
- `/provider reset` should return to the primary provider for future requests in
|
||||
the current session.
|
||||
- The footer/statusline should surface the concrete provider/model that actually
|
||||
handled the latest request.
|
||||
- Session receipts should record both attempted provider and successful
|
||||
provider so cost and debugging information stay truthful.
|
||||
|
||||
## Implementation Slices
|
||||
|
||||
1. Config schema and validation:
|
||||
- parse `fallback_providers` and `[provider_fallback]`
|
||||
- validate known providers, duplicates, missing credentials, and primary
|
||||
self-reference
|
||||
- document the config surface
|
||||
2. Error classification:
|
||||
- define fallback-eligible error kinds
|
||||
- add unit tests for HTTP and transport failures
|
||||
3. Request-shape capability gate:
|
||||
- evaluate tool, thinking, context, and image requirements
|
||||
- add tests for incompatible fallbacks
|
||||
4. Fallback execution:
|
||||
- run retries per provider before moving to the next provider
|
||||
- rebuild auth/base-url/header state for each candidate
|
||||
- block fallback after partial streaming
|
||||
5. UI/receipt integration:
|
||||
- status event
|
||||
- transcript marker
|
||||
- `/provider reset`
|
||||
- receipt fields for attempted and selected provider
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No automatic cost optimization or weighted provider selection.
|
||||
- No silent fallback when authentication or permissions fail.
|
||||
- No fallback after partial assistant content or tool-call deltas have streamed.
|
||||
- No provider/model capability downgrades without an explicit future policy.
|
||||
- No sub-agent-specific fallback policy in the first implementation; sub-agents
|
||||
inherit the same configured fallback chain unless they are given an explicit
|
||||
provider/model override.
|
||||
|
||||
## Credit
|
||||
|
||||
This RFC is based on issue #2574 from @hsdbeebou and PR #2581 from @idling11.
|
||||
The original PR head currently has no net file changes, so this document
|
||||
preserves the useful design direction while tightening the v0.9 contract around
|
||||
truthful provider routing, billing visibility, and capability checks.
|
||||
@@ -1,137 +0,0 @@
|
||||
# Orchestration disposition and the Fleet substrate
|
||||
|
||||
Status: design note (forward-looking)
|
||||
Related: constitution `## Orchestration` regulation; EPIC #3154; #3167; #3205
|
||||
|
||||
## Why this note exists
|
||||
|
||||
A CodeWhale agent, given a build larger than one context could hold,
|
||||
spontaneously orchestrated it: it read the dependency shape, fanned the
|
||||
independent work out to sub-agents across isolated git worktrees, kept its
|
||||
own context for deciding and verifying, refused to trust any worker's
|
||||
"done" until it checked the return, and kept a living backlog so the loop
|
||||
never stalled. Nobody hardcoded that behavior. The model reached for the
|
||||
delegation substrate as a *disposition*.
|
||||
|
||||
That is the thesis worth protecting: **metacognition belongs to the
|
||||
model.** The judgment about when work has outgrown a single hand, how to
|
||||
slice it, who should do each slice, and how to reassemble the pieces is
|
||||
exactly the kind of decision we trust the model to make. We do not want to
|
||||
re-encode it as a state machine.
|
||||
|
||||
This note separates the two halves of that observation so neither
|
||||
swallows the other:
|
||||
|
||||
- The **disposition** — the orchestrator stance — is instilled by the
|
||||
constitution, not by machinery. It is judgment.
|
||||
- **Fleet** — #3154 / #3167 / #3205 — is the **substrate** the disposition
|
||||
reaches for. It is mechanism: durable workers, profiled roles,
|
||||
preconfigured loadouts, receipts.
|
||||
|
||||
The constitution already draws this line in Article IV (*Legacy*):
|
||||
"A principle may name the duty; mechanism carries it." Orchestration is the
|
||||
principle. Fleet is the mechanism. They meet but do not merge.
|
||||
|
||||
## The disposition (the judgment)
|
||||
|
||||
The new `## Orchestration` regulation unifies four existing Tier-3
|
||||
regulations into a single stance rather than adding new rules:
|
||||
|
||||
- *Composition Pattern for Multi-Step Work* — read the shape, plan before
|
||||
you dig.
|
||||
- *Sub-Agent Strategy* — delegate the doing; brief tightly; keep
|
||||
architecture, integration, and verification in the parent.
|
||||
- *Thinking Delegation* — deep reasoning on a sub-problem is a delegation
|
||||
signal, generalized here to all substantial doing, not only reasoning.
|
||||
- *Keeping the Plan Honest* — the plan is the orchestrator's instrument; a
|
||||
stalled parent is the failure the stance exists to prevent.
|
||||
|
||||
The regulation deliberately encodes *only judgment*: when to shift from
|
||||
builder to orchestrator, how to sequence vs. parallelize, how to match a
|
||||
worker to its work and re-route an unfit one, how to isolate parallel
|
||||
streams, and the verify-floor that nothing returned is trusted until it is
|
||||
checked against ground truth (Article II). It carries the small-task
|
||||
carve-out: you do not orchestrate a one-file change.
|
||||
|
||||
Crucially, it names *no* concrete mechanism — no tool names, no role
|
||||
registry, no slot schema. That is what keeps it a disposition. The same
|
||||
stance works whether the substrate underneath is short-lived `agent`
|
||||
fanout today or durable Fleet workers tomorrow.
|
||||
|
||||
## The substrate (Fleet)
|
||||
|
||||
When an agent orchestrates today, it improvises the loadouts: it picks a
|
||||
read-only scout for a lookup, a builder for a slice, a review panel for a
|
||||
design, a verifier for a check — and it re-derives those choices every
|
||||
turn, in prose, from scratch. Fleet is the layer that lets those choices
|
||||
be *pre-set* so the agent reaches for a configured role instead of
|
||||
re-improvising one.
|
||||
|
||||
The Fleet design (per the EPIC) keeps three layers distinct:
|
||||
|
||||
- **Profile layer (#3167)** — `FleetProfile`, `FleetRole`, `FleetSlot`,
|
||||
`FleetLoadout`. Roles already include `scout`, `builder`, `reviewer`,
|
||||
`verifier`, `synthesizer`, **`orchestrator`**, `release`, `security`.
|
||||
A slot says "when this role appears, use this persona, these tools, this
|
||||
permission envelope, this loadout — unless overridden."
|
||||
- **Route layer (#3205)** — Fleet model classes (`strong` / `balanced` /
|
||||
`fast`) and semantic route roles, resolved through the shared route
|
||||
resolver. This is "match the worker to the work" made concrete and
|
||||
provider-agnostic.
|
||||
- **Execution layer (#3154)** — durable worker lifecycle, leases,
|
||||
heartbeats, retries, receipts, artifacts, and synthesis hooks. This is
|
||||
the verify-floor given teeth: a receipt is a checkable record, not a
|
||||
worker's word.
|
||||
|
||||
The mapping from disposition to substrate is close to one-to-one:
|
||||
|
||||
- *read the dependency shape; sequence vs. parallelize* → the orchestrator
|
||||
composes a Fleet run of dependent and independent tasks.
|
||||
- *match the worker to the work* → a `FleetSlot` binds a role to a tool
|
||||
profile and a model class (#3167 + #3205).
|
||||
- *isolate parallel streams; serialize the shared surface* → per-worker
|
||||
workspace bounds and writable paths in the task spec.
|
||||
- *never trust a worker's "done"* → Fleet receipts (`pass` / `fail` /
|
||||
`partial` / `verifier_failed`) instead of a transcript claim.
|
||||
- *keep the loop alive* → durable supervision, planner wakeups on
|
||||
barriers, and resume-from-ledger so progress survives a dropped parent.
|
||||
|
||||
## What a Fleet profile buys the user
|
||||
|
||||
A `FleetProfile` lets a user pre-set the team an agent would otherwise
|
||||
improvise: a `codex-coder` builder slot on a `balanced`/`strong` loadout,
|
||||
a `claude-resolver` slot for conflict and merge work, a `design-panel`
|
||||
reviewer slot that is read-only by default, and a `verifier` slot wired to
|
||||
a deterministic scorer or a fresh-context verify pass. The disposition
|
||||
still decides *whether and how* to delegate this turn; the profile decides
|
||||
*what a delegated worker is made of* so that decision is cheap, repeatable,
|
||||
and auditable rather than re-narrated every run.
|
||||
|
||||
This is the configurability payoff of trusting the model: because the
|
||||
judgment lives in the model and the loadouts live in config, a user can
|
||||
tune their team without touching the agent's reasoning, and the agent can
|
||||
exercise its reasoning without re-litigating provider, model, and
|
||||
permission policy every turn.
|
||||
|
||||
## What this note does not claim
|
||||
|
||||
- It does not propose new constitution machinery. The disposition is prose;
|
||||
the guarantees (ordering, budgets, permission narrowing, receipts) belong
|
||||
to Fleet mechanism per Article IV.
|
||||
- It does not duplicate the Fleet schema. #3167 and #3205 own the profile
|
||||
and loadout shapes; this note only argues *why* they are the right home
|
||||
for the loadout choices the disposition currently improvises.
|
||||
- It does not assert Fleet exists today. The disposition stands on its own
|
||||
on the current `agent` substrate; Fleet is where it lands durably. The
|
||||
regulation is intentionally substrate-agnostic so it does not rot when the
|
||||
substrate underneath it changes.
|
||||
|
||||
## Open questions
|
||||
|
||||
- How does an orchestrator-role worker (#3167) delegate further without
|
||||
unbounded recursive spawning? Delegation depth is a profile concern, but
|
||||
the disposition should know the ceiling exists.
|
||||
- Should the constitution ever *name* Fleet, or stay substrate-agnostic
|
||||
forever? This note's position: stay agnostic. The moment the constitution
|
||||
names a mechanism, the mechanism becomes load-bearing in the place we
|
||||
reserved for judgment.
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
# CodeWhale Smoothness Playbook — Fixing the "Pop In / Pop Out & Bad Timing" Feeling
|
||||
|
||||
Audience: the CodeWhale TUI team. Scope: perceived motion quality — how ephemeral UI (overlays, modals, spinners, toasts, streaming text, transcript cells, sidebar panels) appears, disappears, and coordinates in time. This is the choreography layer that the prior `CODEWHALE_PERFORMANCE_MOTION_AUDIT.md` under-covers. It does not re-litigate raw render micro-perf except where perf causes a *visible* flash or stutter.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
**There is exactly one root cause, and it has two faces.**
|
||||
|
||||
CodeWhale has **no animation clock**. Redraw is on-demand: `App::needs_redraw: bool` (`app/…`, flipped in 83 places in `app.rs` alone) is the *only* signal that a frame should be produced, and `frame_rate_limiter.rs` is a pure *throttle* — `clamp_deadline()` (frame_rate_limiter.rs:55–63) only pushes a requested draw *later* so two draws never land closer than `MIN_FRAME_INTERVAL` (8.33ms, frame_rate_limiter.rs:36). Nothing ever ticks the UI *forward* in time on its own. Because state can only advance when an event arrives, **every state change is a single-frame snap**: an overlay is absent on frame N and fully present on frame N+1. There are no in-between frames to ease through, so nothing can fade, slide, or grow. This is *face one*: **no clock → everything snaps.**
|
||||
|
||||
Layered on top is *face two*: **no shared timing scale.** There are 40+ magic durations scattered across ~30 files (spinner 80ms, forced-repaint cadence 80ms, streaming hysteresis 1200ms/300ms/250ms, toast TTLs 4s/5s/15s, receipt 8s, version hint 12s, thinking throttle 100ms) with no central module and no easing vocabulary anywhere in the crate. Even if two surfaces *did* animate, they'd move at unrelated tempos. This is why the UI feels "uncoordinated" even in the places that technically do move (the spinner and the footer working-strip drift against each other).
|
||||
|
||||
Put together: **things pop instead of transition (no clock), and the few things that do move feel unrelated (no scale).** That is the entire complaint.
|
||||
|
||||
The 3–4 highest-leverage moves, in order:
|
||||
|
||||
1. **Build the animation clock.** A single scheduler that, whenever anything is mid-transition, forces a redraw at a steady ~30–60fps and idles otherwise. This is the keystone — nothing else in the "smooth" column is possible without it. (Section 3.)
|
||||
2. **Add a `motion::timing` token module** — `INSTANT/FAST/BASE/SLOW` durations, a 2–3 curve easing set, and the spinner delay/min-visible constants — and route the existing 40+ magic numbers through it. (Section 3.)
|
||||
3. **Ship the quick wins that need no clock first** — spinner show-delay + min-visible-time, unified 80ms spinner cadence, kill the full-terminal-clear on resize/focus, and minimum-dwell on toasts. These remove the *worst* flashes immediately and buy goodwill while the clock lands. (Section 5A.)
|
||||
4. **Give each surface a real enter/exit** — a small, uniform fade+offset on overlays/modals/toasts/panels driven by the clock and the tokens. Symmetry is the point: things should leave the way they arrived. (Section 4, Section 5B.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Why it feels ugly today — the mental model
|
||||
|
||||
CodeWhale's render loop is **event-driven snap rendering**. Walk the loop in `ui.rs`: an input or engine event arrives, a handler mutates state and sets `app.needs_redraw = true`, and the draw gate (`if needs_redraw && draw_wait.is_none()`, ui.rs:~3493–3503) paints once. Between events the loop *sleeps* on the next terminal event. `frame_rate_limiter.rs` caps how *fast* this can happen; nothing makes it happen *on a rhythm*. The consequence is structural: **a transition that should take 150ms cannot exist**, because there is no mechanism to wake the loop 5 times over those 150ms to advance it. Every "before" state is one frame; every "after" state is the next frame.
|
||||
|
||||
Everything the user perceives as "ugly timing" is a symptom of that one fact, expressed on different surfaces:
|
||||
|
||||
- **Boolean overlay toggles.** Overlays are a `ViewStack` of boxed `ModalView`s. `ViewStack::push()` (views/mod.rs:795) synchronously appends the view; the next draw renders it at full size and opacity via `render_modal_surface`/`render_modal_backdrop` (views/mod.rs:64–102), which paint an opaque block in one pass with no alpha/scale parameter. `ViewStack::apply_action()` handling `ViewAction::Close` (views/mod.rs:876–880) calls `pop()` immediately. No modal struct — `ApprovalView` (approval.rs:1329–1354), `CommandPaletteView` (command_palette.rs:54–59), `ModelPickerView` (model_picker.rs:51–76), `ContextMenuView` (context_menu.rs:34–44) — carries an `entering_at`, `exit_progress`, `opened_at`, or `min_visible` field. A grep for `animation_frame` across the whole `tui/` tree returns zero hits. So the command palette, model/provider/session/file pickers, slash menu, feedback picker, pager, backtrack, context inspector, and the **approval modal** (the single most weighty surface, pushed at ui.rs:10410, popped on `EmitAndClose` at approval.rs:1405) all pop and vanish in one frame, backdrop included.
|
||||
|
||||
- **Flashing / stuttering spinners.** `braille_spinner_frame()` (spinner.rs:40–50) derives its frame index purely from `started.elapsed() / 80ms`. It is only ever called *inside a render pass*. So the spinner only advances when *something else* triggers a redraw. If the app is idle for >80ms — network stall, user not typing — `needs_redraw` stays false, the spinner freezes, and when an unrelated event finally fires it jumps several frames at once (visible stutter). Worse, there is **no show-delay and no min-visible-time**: a tool status flips to `Running` and the spinner renders immediately (history.rs:1605–1612, 1710–1735), and flips to `Success` and it vanishes immediately (footer_ui.rs:258–264 gates purely on `is_loading`; sidebar.rs:1680–1686 returns `None` the instant status != running). A sub-100ms op flashes a glyph for one or two frames — reads as a glitch, not "working."
|
||||
|
||||
- **Inconsistent spinner tempos.** The braille spinner runs at 80ms/frame (spinner.rs:15). The footer working-label uses `now_ms / 400` (footer_ui.rs:267–268) — a different cadence entirely. Sidebar task spinners sample `braille_spinner_frame_for_duration_ms(task.duration_ms)` (sidebar.rs:1683) independently. Each surface reads the wall clock on its own with no shared frame counter, so when the footer, an active tool card, and a sidebar task are all spinning at once they visibly beat against each other.
|
||||
|
||||
- **Layout jumps.** New transcript cells materialize at full height in a single frame: `flatten_from()` appends the cell's full line vector at once (transcript.rs:282–299), shoving existing content upward. When sticky-to-bottom is active, `scroll.set(max_scroll)` fires unconditionally every frame (live_transcript.rs:556–558) — the view jerks down with no ease. Thinking blocks toggle collapsed/expanded via a boolean (history/thinking.rs:84), snapping between fixed line limits (4/6/8 lines, history/thinking.rs:20–22) with no height interpolation. On `flush_active_cell` (app.rs:3857–3912) the finalized cells are appended with no height reservation, so the transcript grows under the reader. Sidebar panels appear/disappear by recomputing `Layout::split()` constraints every frame from boolean predicates (`auto_sidebar_panels`, sidebar.rs:219–237; `sidebar_auto_idle`, sidebar.rs:195–201) — width and panel count snap with zero intermediate frames.
|
||||
|
||||
- **Transients with no lifecycle.** `StatusToast` (app.rs:482–487) is `{ text, level, created_at, ttl_ms }` — no phase field. `active_status_toast()` (app.rs:4165–4195) hard-drops a toast the instant `is_expired()` (app.rs:501–504) is true; the footer renders it at full opacity with no fade (footer_ui.rs / widgets/footer.rs:560–572). There is no *minimum* display floor, so an error that clears in <100ms can flash and vanish before the eye registers it. Retry banners (`retry_status.rs:22–49`, footer.rs:582–601) are identical: a bare `Idle→Active→Idle` enum toggle with no phase and no dwell. Receipts (8s, app.rs:3978), version hints (12s, ui.rs:190), and file-mention candidates (4s, file_mention.rs:246) all vanish the same instant their TTL trips.
|
||||
|
||||
- **Scattered magic durations.** The 40+ constants above live in ~30 files with no `motion` module and **no easing functions anywhere in the codebase**. Values span 5ms to 12,000ms — a ~2400× spread — with no tokenized fast/base/slow. Even the two intentionally-decoupled cadences (120fps render cap vs. 80ms spinner) are undocumented as to *why* they differ, so future contributors will keep inventing new magic numbers.
|
||||
|
||||
- **Resize/focus flash.** On resize and on focus-regain the code calls `draw_app_frame_inner(full_repaint = true)`, which unconditionally issues `terminal.clear()` (ui.rs:9547–9550) — an `ESC[2J` that blanks the whole viewport before ratatui repaints. This is a literal black flash, and on a fast drag it fires repeatedly. On top of the flash, the full transcript per-cell cache is cleared synchronously on any width change (transcript.rs:163–167), re-wrapping every cell on the render thread — the sluggish re-flow the user feels while dragging.
|
||||
|
||||
The through-line: **the codebase has all the *state* for these surfaces and none of the *time*.** Add time, and the ugliness has somewhere to go.
|
||||
|
||||
---
|
||||
|
||||
## 3. The foundational fix — an animation clock + a `motion::timing` token module
|
||||
|
||||
Everything in Section 4 depends on this. Build it first.
|
||||
|
||||
### 3.1 The animation clock / scheduler
|
||||
|
||||
**Goal:** while anything is animating, the loop ticks at a steady 30–60fps; when nothing is animating, it idles exactly as it does today (zero cost when the UI is at rest — this must not become a busy-loop).
|
||||
|
||||
Concrete shape:
|
||||
|
||||
```rust
|
||||
// crates/tui/src/tui/motion/clock.rs
|
||||
pub struct AnimationClock {
|
||||
started: Instant, // fixed origin; all progress is measured from here
|
||||
active: Vec<AnimationHandle>, // in-flight transitions
|
||||
}
|
||||
|
||||
impl AnimationClock {
|
||||
pub fn now_ms(&self) -> u128 { self.started.elapsed().as_millis() }
|
||||
pub fn is_animating(&self) -> bool { !self.active.is_empty() }
|
||||
/// Next instant the loop MUST wake to advance an animation, if any.
|
||||
pub fn next_deadline(&self, now: Instant) -> Option<Instant> { /* now + tick */ }
|
||||
pub fn retire_completed(&mut self, now_ms: u128) { /* drop finished */ }
|
||||
}
|
||||
```
|
||||
|
||||
Integration points (all already exist, all one-line hooks):
|
||||
|
||||
- **Wake the loop.** In the `poll_timeout` computation in `ui.rs` (the block near ui.rs:3506–3537 that already shortens the timeout for `is_loading`, `stream_display_clock`, etc.), fold in `min(poll_timeout, clock.next_deadline())`. When any animation is in flight, the loop now wakes at the next frame boundary even with zero user input. This is the whole trick — it converts "sleep until an event" into "sleep until the next animation frame *or* an event, whichever is sooner."
|
||||
- **Advance and request redraw.** After the frame limiter's `mark_emitted`, if `clock.is_animating()` set `needs_redraw = true` for the following frame. When the last animation retires, stop forcing — idle returns.
|
||||
- **Read progress in render.** Give render access to `clock.now_ms()`. Each animated surface computes its own `progress = ((now_ms - started_ms) / duration_ms).clamp(0.0, 1.0)` and passes it through easing. Because every surface samples the *same* `now_ms`, all motion is in lockstep by construction — this alone fixes the spinner-vs-footer beat problem.
|
||||
|
||||
Tick rate: **30–50ms (20–33fps) is enough for terminal motion** and stays comfortably inside the existing 120fps limiter and the 30fps low-motion cap (frame_rate_limiter.rs:36, LOW_MOTION at 33.33ms). Honor `low_motion`: when set, either skip enter/exit easing (snap, but still respect min-visible-time) or run at the 30fps cap. Motion must be a comfort, never a tax.
|
||||
|
||||
Effort: **large**, but it is the *only* large item that unlocks a dozen medium/small ones. Treat it as the platform.
|
||||
|
||||
### 3.2 `motion::timing` — one tokenized scale + one easing set
|
||||
|
||||
Create `crates/tui/src/tui/motion/timing.rs` and make it the single source of truth. Suggested values (tuned for a terminal, where a few discrete frames already read as "smooth"):
|
||||
|
||||
```rust
|
||||
// Durations — the fast/base/slow spine.
|
||||
pub const INSTANT: Duration = Duration::ZERO;
|
||||
pub const FAST: Duration = Duration::from_millis(120); // snappy feedback, context menus
|
||||
pub const BASE: Duration = Duration::from_millis(200); // standard overlay enter
|
||||
pub const SLOW: Duration = Duration::from_millis(320); // deliberate / weighty (approval)
|
||||
|
||||
// Enter/exit — exits are a touch quicker than enters (leave decisively).
|
||||
pub const OVERLAY_ENTER: Duration = BASE; // 200ms
|
||||
pub const OVERLAY_EXIT: Duration = Duration::from_millis(150);
|
||||
|
||||
// Spinner discipline.
|
||||
pub const SPINNER_FRAME: Duration = Duration::from_millis(80); // keep existing cadence
|
||||
pub const SPINNER_SHOW_DELAY: Duration = Duration::from_millis(150); // suppress instant ops
|
||||
pub const SPINNER_MIN_SHOW: Duration = Duration::from_millis(450); // never flicker
|
||||
|
||||
// Transient dwell (min-visible floors + fades).
|
||||
pub const TRANSIENT_MIN_VISIBLE: Duration = Duration::from_millis(350);
|
||||
pub const TOAST_FADE: Duration = FAST; // 120ms in and out
|
||||
```
|
||||
|
||||
```rust
|
||||
// crates/tui/src/tui/motion/easing.rs — the entire easing vocabulary.
|
||||
pub fn linear(t: f32) -> f32 { t }
|
||||
pub fn ease_out_cubic(t: f32) -> f32 { 1.0 - (1.0 - t).powi(3) } // enters: fast then settle
|
||||
pub fn ease_in_cubic(t: f32) -> f32 { t * t * t } // exits: withdraw decisively
|
||||
pub fn step_half(t: f32) -> f32 { if t < 0.5 { 1.0 } else { 0.0 } } // cursor blink
|
||||
```
|
||||
|
||||
Rule of thumb: **enter = `ease_out_cubic` (arrives eagerly, settles gently); exit = `ease_in_cubic` (accelerates away).** Symmetry with a slightly quicker exit is what reads as "elegant" rather than "sluggish."
|
||||
|
||||
Then **route the existing magic numbers through this module.** `BRAILLE_SPINNER_FRAME_MS` (spinner.rs:15) becomes `SPINNER_FRAME`. The toast TTLs in `classify_status_text` (app.rs:4038–4080) become named dwell tokens. The forced-repaint cadence `UI_STATUS_ANIMATION_MS` (ui.rs:184) references `SPINNER_FRAME` and gets a doc comment explaining the intentional 120fps-cap / 80ms-cadence decoupling (this last one is a *trivial* documentation fix worth doing regardless). One module, imported everywhere, kills the "2400× spread of unrelated numbers" problem and gives every future contributor a vocabulary instead of a fresh literal.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-surface transitions
|
||||
|
||||
Once 3.1 and 3.2 land, apply this table uniformly. Durations reference the Section 3.2 tokens. "Enter" and "exit" are the transition; "delay/min" is the discipline that prevents flicker. Everything uses `ease_out_cubic` on enter and `ease_in_cubic` on exit unless noted.
|
||||
|
||||
| Surface | Enter | Exit | Delay / Min-visible | Notes & anchor |
|
||||
|---|---|---|---|---|
|
||||
| **Command palette / model / provider / session / file picker / slash menu / feedback / pager / backtrack / context inspector** | `BASE` 200ms: alpha 0→1 + slide-down 3–5 rows→0 | `OVERLAY_EXIT` 150ms: reverse | Min-visible `TRANSIENT_MIN_VISIBLE` 350ms (guard `pop()` in `apply_action`) | Add `entering_at`/`exit_progress` to each `ModalView`; `push` records enter, `Close` sets exit instead of removing (views/mod.rs:795, 876) |
|
||||
| **Approval modal** | `SLOW` 320ms: alpha 0→1 + slide-up 3 rows, backdrop dim 0→70% in lockstep | `OVERLAY_EXIT` 150ms | Min-visible **400–500ms** (reuse `requested_at`; block pop until elapsed) | Weightiest surface — earn the extra 120ms. Pushed ui.rs:10410, popped approval.rs:1405 |
|
||||
| **Modal backdrop** | Alpha 0→70% over enter, synced to modal | 70%→0 over exit | — | Parameterize `render_modal_backdrop` (views/mod.rs:94–102) with `opacity: f32` |
|
||||
| **Context menu** | `FAST` 120ms: scale 0.9→1.0 centered on cursor + alpha | `FAST` 120ms | — | Lightweight; punchy is correct. Scale origin = `(column, row)` (context_menu.rs:34–44) |
|
||||
| **Running spinners (tool cards, footer strip, sidebar tasks)** | Appear only after `SPINNER_SHOW_DELAY` 150ms | Hold to `SPINNER_MIN_SHOW` 450ms after first frame, then a 200ms completion glyph (✓/•) before hiding | Delay 150ms, min-show 450ms | Wrap in a `SpinnerDisplay` that tracks `first_shown_at`; gate `braille_spinner_frame` calls in history.rs:1710, footer_ui.rs:258, sidebar.rs:1680 |
|
||||
| **Spinner cadence** | — | — | Fixed `SPINNER_FRAME` 80ms sampled from `clock.now_ms()` | Replace independent wall-clock samples (footer_ui.rs:267, sidebar.rs:1683) with the shared clock so all spinners lockstep |
|
||||
| **Streaming reveal** | Paced ~2–4 chars/frame Smooth, ~8–15 CatchUp, `linear` | — | — | Add a per-frame reveal budget in `commit_tick.rs` (currently all-or-one, commit_tick.rs:154–157); optionally a `reveal_cursor`. Requires the clock to tick between SSE chunks |
|
||||
| **Streaming cursor** | — | — | Blink `step_half`, 600ms period, start after 50ms | Time-modulate `REASONING_CURSOR` (history/thinking.rs:174–176) using `clock.now_ms()` — mirror the pattern already in streaming_thinking.rs:105–119 |
|
||||
| **Thinking block collapse/expand** | Height lerp prev→target, `ease_out_cubic` | Same, symmetric | `BASE`–`SLOW` 200–280ms | Interpolate line count in `render_thinking` (history/thinking.rs:79–166) instead of snapping between fixed limits |
|
||||
| **New transcript cell** | Height grow 30%→60%→100% over 3–4 frames, or fade-in over `FAST` | — | — | Stage in `flatten_from` (transcript.rs:282–299); cheapest correct version is a 3-frame height ramp keyed off a per-cell `entered_at` |
|
||||
| **Scroll-to-bottom (sticky)** | Ease scroll last→target `ease_out_cubic` 150–200ms | — | — | Replace unconditional `scroll.set(max_scroll)` (live_transcript.rs:556–558) with an interpolated target |
|
||||
| **Status toasts** | `TOAST_FADE` 120ms fade+slide-up 0.5 row | `TOAST_FADE` 120ms fade+slide-down | Min-visible `TRANSIENT_MIN_VISIBLE` 350ms; optional 80–120ms entry delay for errors so <100ms hiccups never show | Add `phase` + `phase_started_at` to `StatusToast` (app.rs:482–487); `is_expired` becomes `elapsed > ttl + fade` |
|
||||
| **Retry banner** | `FAST` 120ms fade+scale 0.9→1 | `FAST` 120ms | Min-visible **400ms** (so instant-success retries are still seen) | Same phase pattern on `RetryState`/`RetryBanner` (retry_status.rs:22–49) |
|
||||
| **Receipts / version hint / file candidates** | fade-in `FAST` | fade-out `FAST` 150ms | Keep existing TTLs (retokenized) | Add `exit_started` (app.rs:3978, ui.rs:190, file_mention.rs:246); remove only after fade completes |
|
||||
| **Sidebar width (collapse/expand)** | Width lerp current→target `ease_out_cubic` 200ms | Same | — | Animate the `Constraint` percentage instead of flipping `sidebar_auto_idle` (sidebar.rs:195) into an instant `Layout::split` |
|
||||
| **Sidebar panels (todo/tasks/agents/context)** | fade+height-clip in, `BASE` 150ms | 120ms; keep exiting panel alive until fade done | — | Diff `auto_sidebar_panels` (sidebar.rs:219–237) frame-over-frame; record enter/exit per panel |
|
||||
| **Subagent rows** | per-row fade `FAST` with +50ms stagger between rows | 100ms | — | Add `entered_at`/`exited_at` to `SidebarAgentRow` (sidebar.rs:2488–2501); keep evicted rows alive one fade |
|
||||
| **Completed task row (8s TTL)** | — | 150ms fade before drop | Hold ≥400ms after completion before fade starts | `active_tool_row_visibility` (sidebar.rs:1728–1752) currently binary at TTL |
|
||||
| **Resize** | No fade — *eliminate the flash* (Section 5A) | — | Debounce rapid drags 150–200ms into one draw | Replace unconditional `terminal.clear()` (ui.rs:9547–9550) with selective/diff repaint |
|
||||
| **Focus change (region → region)** | Cross-fade focus indicator old→new `FAST` 150ms | — | — | `set_sidebar_focus` currently mutates the enum + `needs_redraw` with no transition |
|
||||
|
||||
---
|
||||
|
||||
## 5. Ranked recommendations
|
||||
|
||||
Two buckets. **Bucket A ships value *before* the clock exists** — do these first; they kill the worst flashes and are all small/medium. **Bucket B is the motion system** — the clock, the tokens, and the per-surface transitions that give the UI its polish.
|
||||
|
||||
### Bucket A — quick wins, no animation clock required
|
||||
|
||||
Ordered by impact-per-effort.
|
||||
|
||||
1. **Eliminate the full-terminal-clear flash on resize & focus.**
|
||||
- *Symptom fixed:* the whole viewport blanks black on every resize and every app-switch-back; repeats on a fast drag.
|
||||
- *Change:* stop calling `terminal.clear()` unconditionally when `full_repaint` is true (ui.rs:9547–9550). On focus-regain, rely on `needs_redraw` + ratatui's diff (a debounce path already exists near ui.rs:3637). On resize, only clear when the viewport *grew*; for shrink/same-size let the diff renderer handle it. Add a 150–200ms resize debounce (ui.rs:3655–3728) so a drag coalesces into one draw.
|
||||
- *Effort:* medium. Highest visible payoff of any single change.
|
||||
|
||||
2. **Spinner show-delay + min-visible-time.**
|
||||
- *Symptom fixed:* sub-100ms ops flash a spinner glyph (looks like a glitch); the strip pops out before the eye registers it.
|
||||
- *Change:* introduce a `SpinnerDisplay { first_shown_at }` wrapper. Don't render the glyph until `SPINNER_SHOW_DELAY` (150ms) has elapsed; once shown, keep it for `SPINNER_MIN_SHOW` (450ms) even if the op finished, then a brief completion marker. Gate the call sites at history.rs:1710, footer_ui.rs:258, sidebar.rs:1680. *No clock needed* — these are wall-clock comparisons in the existing render path.
|
||||
- *Effort:* small–medium.
|
||||
|
||||
3. **Unify spinner cadence.**
|
||||
- *Symptom fixed:* footer working-strip (`now_ms/400`) beats against the 80ms braille spinner and sidebar spinners.
|
||||
- *Change:* pick `SPINNER_FRAME` (80ms) as the one cadence; drive every spinner from a single `app.animation_frame` (a `u64` bumped once per redraw) instead of independent wall-clock reads (footer_ui.rs:267, sidebar.rs:1683). Interim step before the real clock; the clock later subsumes it.
|
||||
- *Effort:* small.
|
||||
|
||||
4. **Minimum-dwell + entry-suppression for toasts and retry banners.**
|
||||
- *Symptom fixed:* a network error that clears in <100ms flashes and vanishes unseen; a fast-success retry banner never registers.
|
||||
- *Change:* track `shown_at` + `min_display_duration` (350–400ms) on `StatusToast` (app.rs:482–487) and `RetryState`; refuse expiry until the floor passes regardless of TTL. For error/warning toasts, add an 80–120ms pending window so ultra-fast recoveries are discarded before they ever show (push logic at app.rs:3922). Also retokenize the TTLs (app.rs:4038–4080) through `motion::timing`.
|
||||
- *Effort:* small.
|
||||
|
||||
5. **Scroll anchoring on resize (no new flash, less disorientation).**
|
||||
- *Symptom fixed:* content appears to "jump" on resize even though scroll position is technically preserved (app.rs:4210–4216) — no visual bridge.
|
||||
- *Change:* keep the anchor line centered (±3 rows) rather than pinned to top, so the reader's eye tracks it across a re-wrap. A brief 200–300ms highlight on the anchored line is a nice-to-have but can wait for the clock.
|
||||
- *Effort:* small.
|
||||
|
||||
6. **Defer the transcript width-cache invalidation by one frame.**
|
||||
- *Symptom fixed:* perceptible stutter while dragging to a new width, as every markdown cell re-wraps synchronously on the render thread.
|
||||
- *Change:* on width change, draw once at the old width, then invalidate `per_cell` (transcript.rs:163–167) on the next loop iteration so the debounce and scroll anchoring coordinate — or move markdown re-wrap off the render thread.
|
||||
- *Effort:* medium.
|
||||
|
||||
7. **Document the 120fps-cap / 80ms-spinner decoupling.**
|
||||
- *Symptom fixed:* future contributors keep inventing magic numbers because the existing ones aren't explained.
|
||||
- *Change:* comment `frame_rate_limiter.rs:36`, `ui.rs:184`, and `spinner.rs:15` to state that the render cap prevents SSE-driven waste while the spinner cadence is the *perceptual* motion rate, and that they are intentionally independent layers.
|
||||
- *Effort:* trivial.
|
||||
|
||||
### Bucket B — the motion system
|
||||
|
||||
8. **Build the `AnimationClock` scheduler (Section 3.1).**
|
||||
- *Symptom fixed:* the root cause — nothing can ease over time, spinners stall when idle, streaming can't reveal between chunks.
|
||||
- *Change:* steady 30–50fps tick while animating, idle otherwise; fold `next_deadline()` into the `poll_timeout` calc (ui.rs:3506–3537); expose `now_ms()` to render. Honor `low_motion`.
|
||||
- *Effort:* large. The keystone.
|
||||
|
||||
9. **Add `motion::timing` + `motion::easing` and route the 40+ magic numbers through them (Section 3.2).**
|
||||
- *Symptom fixed:* "nothing feels coordinated" — one fast/base/slow scale and one easing set replace the 2400× spread.
|
||||
- *Effort:* large (mechanical breadth, low risk). Land alongside #8.
|
||||
|
||||
10. **Overlay & modal enter/exit + backdrop fade.**
|
||||
- *Symptom fixed:* every picker/palette/menu and the approval modal snap in and out; backdrop hard-cuts.
|
||||
- *Change:* `entering_at`/`exit_progress`/`opened_at` on each `ModalView`; `push` schedules enter, `Close` schedules exit and defers `pop()` until the exit completes (views/mod.rs:795, 876); `render_modal_surface`/`render_modal_backdrop` (views/mod.rs:64–102) take alpha/scale/offset. Enforce min-visible in `apply_action`.
|
||||
- *Effort:* medium (per-surface, but one shared pattern).
|
||||
|
||||
11. **Streaming reveal pacing + cursor blink.**
|
||||
- *Symptom fixed:* text arrives in bursty batches; the caret is static.
|
||||
- *Change:* per-frame char budget in `commit_tick.rs` (replace all-or-one at commit_tick.rs:154–157); blink `REASONING_CURSOR` off `clock.now_ms()` (history/thinking.rs:174–176). Requires the clock to tick between SSE chunks.
|
||||
- *Effort:* medium.
|
||||
|
||||
12. **Transcript cell grow-in, sticky-scroll easing, thinking-block height animation.**
|
||||
- *Symptom fixed:* content shoves the transcript, sticky-scroll jerks, folds snap.
|
||||
- *Change:* staged height in `flatten_from` (transcript.rs:282–299); interpolated `scroll.set` target (live_transcript.rs:556–558); height-lerp in `render_thinking` (history/thinking.rs:79–166).
|
||||
- *Effort:* large (transcript is the hottest, most cache-sensitive path — sequence carefully).
|
||||
|
||||
13. **Sidebar width/panel/row transitions + focus cross-fade.**
|
||||
- *Symptom fixed:* sidebar collapse, panel appear/disappear, and subagent rows all pop; focus jumps with no bridge.
|
||||
- *Change:* animate the width `Constraint` (sidebar.rs:195); diff panels/rows frame-over-frame with per-item enter/exit (sidebar.rs:219–237, 2488–2501); fade completed-task rows before drop (sidebar.rs:1728–1752); cross-fade focus indicator.
|
||||
- *Effort:* medium–large.
|
||||
|
||||
---
|
||||
|
||||
## 6. Prioritized checklist
|
||||
|
||||
Do them in this order. A–1 through A–7 are safe to ship independently and immediately; they remove the loudest complaints. B–8 and B–9 are the platform and should land together. B–10 onward are pure payoff once the platform exists.
|
||||
|
||||
- [ ] **A–1. Kill the full-terminal-clear flash on resize + focus; debounce rapid resizes.** *(medium — biggest single visible win)* — ui.rs:9547–9550, ui.rs:3655–3728, ui.rs:3637
|
||||
- [ ] **A–2. Spinner show-delay (150ms) + min-visible-time (450ms) + completion marker.** *(small–medium)* — history.rs:1710, footer_ui.rs:258, sidebar.rs:1680
|
||||
- [ ] **A–3. Unify all spinners to one 80ms cadence via a shared `animation_frame` counter.** *(small)* — footer_ui.rs:267, sidebar.rs:1683, spinner.rs:15
|
||||
- [ ] **A–4. Min-dwell (350–400ms) + fast-error entry-suppression on toasts & retry banners; retokenize TTLs.** *(small)* — app.rs:482–487, 3922, 4038–4080, retry_status.rs:22–49
|
||||
- [ ] **A–5. Center-anchor scroll on resize (±3 rows) so content doesn't appear to jump.** *(small)* — app.rs:4210–4216
|
||||
- [ ] **A–6. Defer transcript width-cache invalidation one frame (or move re-wrap off the render thread).** *(medium)* — transcript.rs:163–167
|
||||
- [ ] **A–7. Document the 120fps-cap / 80ms-spinner decoupling.** *(trivial)* — frame_rate_limiter.rs:36, ui.rs:184, spinner.rs:15
|
||||
- [ ] **B–8. Build `AnimationClock` — steady 30–50fps while animating, idle otherwise; wire into `poll_timeout`; honor low-motion.** *(large — keystone)* — new `motion/clock.rs`, ui.rs:3506–3537
|
||||
- [ ] **B–9. Create `motion::timing` + `motion::easing`; route the 40+ magic durations through them.** *(large, mechanical)* — new `motion/timing.rs`, `motion/easing.rs`
|
||||
- [ ] **B–10. Overlay/modal enter+exit + backdrop fade + enforced min-visible (all pickers, palette, menus, approval).** *(medium)* — views/mod.rs:64–102, 795, 876; approval.rs:1329–1354, 1405
|
||||
- [ ] **B–11. Streaming reveal pacing (per-frame char budget) + cursor blink.** *(medium)* — commit_tick.rs:154–157, history/thinking.rs:174–176
|
||||
- [ ] **B–12. Transcript cell grow-in + sticky-scroll easing + thinking-block height animation.** *(large — hot path, sequence carefully)* — transcript.rs:282–299, live_transcript.rs:556–558, history/thinking.rs:79–166
|
||||
- [ ] **B–13. Sidebar width/panel/row transitions + focus cross-fade.** *(medium–large)* — sidebar.rs:195, 219–237, 1728–1752, 2488–2501
|
||||
|
||||
The test for "done" is simple and human: open a picker, approve a tool, watch a short tool run, resize the window, and let a response stream — and none of it should *pop*. It should arrive, and it should leave the way it arrived.
|
||||
Reference in new issue
Block a user