Skip to content
Merged
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Heavy pull requests now run the real Linux workspace nextest, doctest, and
lockfile lanes directly on GitHub Actions instead of reporting a green
placeholder while waiting for a branch-specific CNB mirror (#5547).
- The goal-continuation quiet period (`[goal] continuation_delay_seconds`,
added in #5508) now applies on every dispatch path. Previously the
within-turn dispatch hook fired the next continuation prompt immediately
whenever a model step ended with the goal still active, so CLI-resumed and
host-managed sessions never observed the configured delay (measured
end-to-start gaps of ~9 s with a 300 s delay configured). The wait now
runs unconditionally inside `goal_continuation_message_if_needed`,
remains cancellable, and pause/clear/terminal `update_goal` calls still
cancel a pending pass (#5534).
- Two unit tests no longer depend on process-global environment state that
sibling tests mutate concurrently.
`route_budget::tests::v4_trigger_uses_window_percent_when_it_fits_spendable_input`
asserted no-override output-budget values while reading
`CODEWHALE_MAX_OUTPUT_TOKENS` / `DEEPSEEK_MAX_OUTPUT_TOKENS` without
holding `lock_test_env()`, so a concurrent writer could flip the value
mid-assertion (the order-dependent full-suite flake).
`prompts::tests::system_prompt_prefix_never_leaks_private_content` read the
real home via `HOME`/`USERPROFILE`, so a machine with
`~/.codewhale/instructions.md` leaked its absolute path into the prompt and
failed the no-private-paths assertion unless a sibling's temporary `HOME`
guard happened to be live. Both tests now hold the env barrier and pin the
variables (the route-budget test removes the overrides; the prompts test
points `HOME`/`USERPROFILE` at a scratch dir). Assertions unchanged.

## [0.9.11] - 2026-08-22

Expand Down
26 changes: 22 additions & 4 deletions crates/cli/tests/diagnostic_dispatch_read_only.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,16 @@ fn dispatcher_diagnostics_are_in_process_and_read_only() {
// dispatches through `run_tui_in_process` -> `codewhale_tui::run`. No
// `DEEPSEEK_TUI_BIN` sibling is spawned, so there is no receipt to read;
// assert the in-process behavior and the read-only invariants instead.
let output = Command::new(codewhale_binary())
let mut command = Command::new(codewhale_binary());
command
.args(args)
.env_clear()
.env("HOME", &sealed_home)
.env("USERPROFILE", &sealed_home)
.env("CODEWHALE_HOME", &codewhale_home)
.env("CODEWHALE_SECRET_BACKEND", "file")
.output()
.expect("run dispatcher diagnostic");
.env("CODEWHALE_SECRET_BACKEND", "file");
preserve_host_rustup_home(&mut command);
let output = command.output().expect("run dispatcher diagnostic");

assert!(
output.status.success(),
Expand Down Expand Up @@ -189,3 +190,20 @@ fn codewhale_binary() -> PathBuf {
path.push(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
path
}

/// A rustup shim may initialize its own toolchain state below `$HOME` when
/// `doctor` asks `rustc --version`. Preserve an already-configured toolchain
/// root so this test isolates Codewhale's own state contract.
fn preserve_host_rustup_home(command: &mut Command) {
let rustup_home = std::env::var_os("RUSTUP_HOME")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(".rustup"))
.filter(|path| path.is_dir())
});
if let Some(rustup_home) = rustup_home {
command.env("RUSTUP_HOME", rustup_home);
}
}
23 changes: 23 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Heavy pull requests now run the real Linux workspace nextest, doctest, and
lockfile lanes directly on GitHub Actions instead of reporting a green
placeholder while waiting for a branch-specific CNB mirror (#5547).
- The goal-continuation quiet period (`[goal] continuation_delay_seconds`,
added in #5508) now applies on every dispatch path. Previously the
within-turn dispatch hook fired the next continuation prompt immediately
whenever a model step ended with the goal still active, so CLI-resumed and
host-managed sessions never observed the configured delay (measured
end-to-start gaps of ~9 s with a 300 s delay configured). The wait now
runs unconditionally inside `goal_continuation_message_if_needed`,
remains cancellable, and pause/clear/terminal `update_goal` calls still
cancel a pending pass (#5534).
- Two unit tests no longer depend on process-global environment state that
sibling tests mutate concurrently.
`route_budget::tests::v4_trigger_uses_window_percent_when_it_fits_spendable_input`
asserted no-override output-budget values while reading
`CODEWHALE_MAX_OUTPUT_TOKENS` / `DEEPSEEK_MAX_OUTPUT_TOKENS` without
holding `lock_test_env()`, so a concurrent writer could flip the value
mid-assertion (the order-dependent full-suite flake).
`prompts::tests::system_prompt_prefix_never_leaks_private_content` read the
real home via `HOME`/`USERPROFILE`, so a machine with
`~/.codewhale/instructions.md` leaked its absolute path into the prompt and
failed the no-private-paths assertion unless a sibling's temporary `HOME`
guard happened to be live. Both tests now hold the env barrier and pin the
variables (the route-budget test removes the overrides; the prompts test
points `HOME`/`USERPROFILE` at a scratch dir). Assertions unchanged.

## [0.9.11] - 2026-08-22

Expand Down
5 changes: 5 additions & 0 deletions crates/tui/src/commands/groups/config/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3696,6 +3696,11 @@ mod tests {
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
// Neutralize the SSH markers: production intentionally caps motion
// over SSH, and the suite routinely runs inside one.
let _ssh_client = EnvVarGuard::remove("SSH_CLIENT");
let _ssh_connection = EnvVarGuard::remove("SSH_CONNECTION");
let _ssh_tty = EnvVarGuard::remove("SSH_TTY");
let prev_term_program = env::var_os("TERM_PROGRAM");
// Safety: test-only environment mutation guarded by EnvGuard's lock.
unsafe {
Expand Down
30 changes: 28 additions & 2 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2410,9 +2410,35 @@ impl Engine {
else {
continue;
};
// Host-injected tokens carry their own quiet period:
// host-managed sessions never run the engine-owned
// scheduler, so this arm is their only continuation
// dispatch site and must honor
// [goal] continuation_delay_seconds itself before
// dispatching. The wait is biased-cancellable (Esc,
// steer, or host cancel always wins over a racing
// expiry), and the live goal is re-read below only
// after it, so a pause/clear/complete/blocked landing
// during the quiet period cancels the pass and
// failures never continue. Engine-owned tokens (Some)
// already waited out ready_at in the scheduler and
// keep those semantics untouched.
if engine_schedule_id.is_none()
&& crate::goal_loop::await_continuation_wait(
crate::goal_loop::continuation_wait(
self.config.goal_continuation_delay_seconds,
),
&self.cancel_token,
)
.await
== crate::goal_loop::ContinuationWaitOutcome::Cancelled
{
continue;
}
// Status controls queued while the previous turn was
// running are processed before this operation. Re-read
// the live goal now so pause/clear/complete/blocked can
// running are processed before this operation, and a
// host-injected quiet period has now elapsed. Re-read
// the live goal so pause/clear/complete/blocked can
// cancel a stale continuation without starting a turn.
let (content, goal_snapshot) = match self.goal_continuation_if_active() {
GoalContinuationAction::Inactive => continue,
Expand Down
233 changes: 233 additions & 0 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1890,6 +1890,239 @@ async fn goal_pause_during_configured_delay_cancels_pending_continuation() {
.expect("engine task");
}

#[tokio::test]
async fn host_injected_goal_continuation_waits_out_the_quiet_period() {
// Host-managed sessions never run the engine-owned scheduler
// (schedule_goal_continuation is gated on !host_managed_turns), so the
// host injects ContinueGoal with engine_schedule_id None and this arm is
// the only dispatch site. A positive configured delay must be awaited
// before the provider request starts.
let model = std::sync::Arc::new(FailingGoalModelClient {
calls: std::sync::atomic::AtomicUsize::new(0),
message: "the host-injected continuation must wait first".to_string(),
});
let config = goal_custom_route_config();
let client: crate::core::model_client::SharedModelClient = model.clone();
let (engine, handle) = Engine::new_with_model_client(
EngineConfig {
model: "local-model".to_string(),
snapshots_enabled: false,
terminal_chrome_enabled: false,
goal_objective: Some("wait out the cadence".to_string()),
goal_continuation_delay_seconds: 1,
..EngineConfig::default()
},
&config,
client,
);
engine
.config
.goal_state
.lock()
.expect("goal lock")
.sync_from_host_status(
Some("wait out the cadence"),
None,
crate::tools::goal::GoalStatus::Active,
);
let run_task = tokio::spawn(engine.run());

let queued_at = Instant::now();
handle
.send(Op::ContinueGoal {
dynamic_tools: Vec::new(),
engine_schedule_id: None,
})
.await
.expect("queue host-injected continuation");
{
let mut events = handle.rx_event.write().await;
tokio::time::timeout(Duration::from_secs(3), async {
while let Some(event) = events.recv().await {
if matches!(event, Event::TurnStarted { .. }) {
break;
}
}
})
.await
.expect("host-injected continuation did not dispatch after the quiet period");
}
let elapsed = queued_at.elapsed();
assert!(
elapsed >= Duration::from_millis(900),
"dispatch must wait out the configured quiet period, dispatched after {elapsed:?}"
);

let _ = tokio::time::timeout(model_turn_event_timeout(), handle.get_session_snapshot())
.await
.expect("host-injected delayed turn did not settle")
.expect("session snapshot after delayed host-injected dispatch");
assert_eq!(
model.calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"the host-injected token must dispatch exactly one provider request"
);

handle.send(Op::Shutdown).await.expect("shutdown engine");
tokio::time::timeout(model_turn_event_timeout(), run_task)
.await
.expect("engine did not shut down after host-injected delay")
.expect("engine task");
}

#[tokio::test]
async fn host_injected_goal_continuation_with_zero_delay_dispatches_immediately() {
let model = std::sync::Arc::new(FailingGoalModelClient {
calls: std::sync::atomic::AtomicUsize::new(0),
message: "zero-delay host-injected dispatch proof".to_string(),
});
let config = goal_custom_route_config();
let client: crate::core::model_client::SharedModelClient = model.clone();
let (engine, handle) = Engine::new_with_model_client(
EngineConfig {
model: "local-model".to_string(),
snapshots_enabled: false,
terminal_chrome_enabled: false,
goal_objective: Some("dispatch without a cadence".to_string()),
goal_continuation_delay_seconds: 0,
..EngineConfig::default()
},
&config,
client,
);
engine
.config
.goal_state
.lock()
.expect("goal lock")
.sync_from_host_status(
Some("dispatch without a cadence"),
None,
crate::tools::goal::GoalStatus::Active,
);
let run_task = tokio::spawn(engine.run());

handle
.send(Op::ContinueGoal {
dynamic_tools: Vec::new(),
engine_schedule_id: None,
})
.await
.expect("queue zero-delay host-injected continuation");
{
let mut events = handle.rx_event.write().await;
tokio::time::timeout(Duration::from_secs(1), async {
while let Some(event) = events.recv().await {
if matches!(event, Event::TurnStarted { .. }) {
break;
}
}
})
.await
.expect("a zero-delay host-injected continuation must dispatch immediately");
}

let _ = tokio::time::timeout(model_turn_event_timeout(), handle.get_session_snapshot())
.await
.expect("zero-delay host-injected turn did not settle")
.expect("session snapshot after zero-delay dispatch");
assert_eq!(
model.calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"zero delay must not suppress the host-injected dispatch"
);

handle.send(Op::Shutdown).await.expect("shutdown engine");
tokio::time::timeout(model_turn_event_timeout(), run_task)
.await
.expect("engine did not shut down after zero-delay dispatch")
.expect("engine task");
}

#[tokio::test]
async fn cancellation_during_host_injected_continuation_wait_never_dispatches() {
let model = std::sync::Arc::new(FailingGoalModelClient {
calls: std::sync::atomic::AtomicUsize::new(0),
message: "a cancelled host-injected wait must never start".to_string(),
});
let config = goal_custom_route_config();
let client: crate::core::model_client::SharedModelClient = model.clone();
let (engine, handle) = Engine::new_with_model_client(
EngineConfig {
model: "local-model".to_string(),
snapshots_enabled: false,
terminal_chrome_enabled: false,
goal_objective: Some("cancel mid-cadence".to_string()),
goal_continuation_delay_seconds: 1,
..EngineConfig::default()
},
&config,
client,
);
engine
.config
.goal_state
.lock()
.expect("goal lock")
.sync_from_host_status(
Some("cancel mid-cadence"),
None,
crate::tools::goal::GoalStatus::Active,
);
let run_task = tokio::spawn(engine.run());

handle
.send(Op::ContinueGoal {
dynamic_tools: Vec::new(),
engine_schedule_id: None,
})
.await
.expect("queue host-injected continuation");
// Enter the quiet period, then cancel: the biased wait must drop the
// pending pass instead of dispatching when the period would have elapsed.
tokio::time::sleep(Duration::from_millis(100)).await;
handle.cancel();

// A dispatch bug would surface a TurnStarted once the 1s period expires;
// a correct cancellation settles back into the mailbox loop silently.
let dispatched = {
let mut events = handle.rx_event.write().await;
tokio::time::timeout(Duration::from_secs(2), async {
loop {
let Some(event) = events.recv().await else {
break false;
};
if matches!(event, Event::TurnStarted { .. }) {
break true;
}
}
})
.await
.unwrap_or(false)
};
assert!(
!dispatched,
"cancellation during the host-injected quiet period must never dispatch"
);

// The engine must keep accepting controls after the cancelled wait.
let _ = tokio::time::timeout(model_turn_event_timeout(), handle.get_session_snapshot())
.await
.expect("engine did not accept controls after the cancelled wait")
.expect("session snapshot after cancelled wait");
assert_eq!(
model.calls.load(std::sync::atomic::Ordering::SeqCst),
0,
"the cancelled host-injected token must never reach the provider"
);

handle.send(Op::Shutdown).await.expect("shutdown engine");
tokio::time::timeout(model_turn_event_timeout(), run_task)
.await
.expect("engine did not shut down after cancelled wait")
.expect("engine task");
}

#[tokio::test]
async fn queued_not_started_turn_cancels_older_goal_continuation() {
let objective = "stop when the queued turn cannot start";
Expand Down
Loading
Loading