fix(runtime): Runtime API approvals never auto-deny; one [approval] timeout_seconds

The Runtime API approval path (what the desktop app and web use) denied a
pending approval on its own after 300 seconds and interrupted the turn,
because it borrowed `[tools] user_input_timeout_seconds` with a 300s
default. CURRENT_DECISIONS §21: an approval waits for the person (sweep
B3).

- RuntimeThreadManager::approval_decision_timeout now reads the one
  approval clock the TUI card already uses, `[approval] timeout_seconds`:
  absent or 0 waits until the person answers or stops the turn. A set
  value still refuses fail-closed and is reported as a timeout (the
  existing approval.timeout event).
- `[tools] user_input_timeout_seconds` governs request_user_input only.
- docs/CONFIGURATION.md: both keys say what they govern.

Tests: runtime_threads::tests::runtime_approvals_wait_indefinitely_unless_approval_timeout_is_set
(new) passes in the 1099 passed / 0 failed focused run (runtime_threads::
226+ tests, task_manager approval-suspension tests included).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-25 13:40:40 -07:00
1 parent ab428ba1a7
commit ed241c3a7f
3 files changed
+58 -21

No files matched your search

+6 -10
View File
@@ -598,7 +598,6 @@ where
}
const RUNTIME_RESTART_REASON: &str = "Interrupted by process restart";
const EMPTY_TURN_REASON: &str = "Turn completed without engine output";
const APPROVAL_DECISION_TIMEOUT: Duration = Duration::from_secs(300);
const DYNAMIC_TOOL_RESULT_TIMEOUT: Duration = Duration::from_secs(300);
#[cfg(test)]
@@ -610,10 +609,11 @@ static TEST_DYNAMIC_TOOL_RESULT_TIMEOUT_MS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
impl RuntimeThreadManager {
/// Wait for one external approval decision. `[tools]
/// user_input_timeout_seconds` governs (#6003): absent uses the built-in
/// default, an explicit 0 returns `None` and the decision waits
/// indefinitely.
/// Wait for one external approval decision. The one approval clock,
/// `[approval] timeout_seconds`, governs here as it does for the TUI
/// card: absent or `0` returns `None` and the decision waits until the
/// person answers or stops the turn (CURRENT_DECISIONS §21). A GPUI or
/// web approval is never denied on the user's behalf by default.
pub(crate) fn approval_decision_timeout(&self) -> Option<Duration> {
#[cfg(test)]
{
@@ -622,11 +622,7 @@ impl RuntimeThreadManager {
return Some(Duration::from_millis(ms));
}
}
match self.read_config().user_input_timeout() {
Some(wait) if wait.is_zero() => None,
Some(wait) => Some(wait),
None => Some(APPROVAL_DECISION_TIMEOUT),
}
self.read_config().approval_timeout()
}
}
+40
View File
@@ -18235,3 +18235,43 @@ async fn canonical_sessions_root_is_resolved_off_the_ui_runtime_and_cached() ->
assert_ne!(cached, sessions, "the fixture spells the root two ways");
Ok(())
}
/// B3: Runtime API approvals (GPUI, web) follow the one approval clock,
/// `[approval] timeout_seconds`. Unset, nothing denies on the user's behalf;
/// `[tools] user_input_timeout_seconds` no longer bounds approvals.
#[test]
fn runtime_approvals_wait_indefinitely_unless_approval_timeout_is_set() -> Result<()> {
let manager = test_manager(test_runtime_dir())?;
assert_eq!(manager.approval_decision_timeout(), None);
let mut config = Config::default();
config.tools = Some(crate::config::ToolsConfig {
user_input_timeout_seconds: Some(300),
..Default::default()
});
let manager = RuntimeThreadManager::open(
config.clone(),
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
assert_eq!(
manager.approval_decision_timeout(),
None,
"the question clock does not bound approvals"
);
config.approval = Some(crate::config::ApprovalConfig {
timeout_seconds: Some(45),
..Default::default()
});
let manager = RuntimeThreadManager::open(
config,
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
assert_eq!(
manager.approval_decision_timeout(),
Some(Duration::from_secs(45))
);
Ok(())
}
+12 -11
View File
@@ -2039,11 +2039,12 @@ reasoning contract, and all four membership ids omit generic sampling fields.
default_selection = "allow_once"
```
- `[approval] timeout_seconds` (integer, optional): bound how long an
interactive approval card may wait. When the window elapses the card
resolves to **deny** — fail-closed, matching the external approval path —
and the transcript records that the bound denied the call, not the
operator. Omitted or `0` waits indefinitely, which stays the interactive
default; values above 24h clamp with a warning (#6101).
approval may wait — the TUI card and the Runtime API approvals the desktop
app and web use alike. When the window elapses the call is refused
(fail-closed) and recorded as a timeout, not as the operator's denial.
Omitted or `0` waits indefinitely, which is the default everywhere: no
approval is ever denied on your behalf unless you set this. Values above
24h clamp with a warning (#6101).
```toml
[approval]
@@ -2928,10 +2929,10 @@ schema (`minItems` / `maxItems`), its model-visible description, and the
payload validator. A rejected payload names the key to raise, so the model can
either resize the batch or tell the user which setting to change.
### User-input / approval wait timeout
### User-input wait timeout
Questions from `request_user_input` and approval decisions wait a bounded
time and then cancel with a timeout (#6003). The default is 300 seconds.
Questions from `request_user_input` wait a bounded time and then cancel with
a timeout (#6003). The default is 300 seconds.
Raise it when you step away or read carefully, or set `0` to wait forever
(overnight automation, long human review). Headless `exec` runs have no
responder, so `request_user_input` is withheld there by default:
@@ -2942,9 +2943,9 @@ the model reports the tool absent and finishes instead of stalling.
user_input_timeout_seconds = 300 # default 300; 0 disables the timeout; clamped to 86400 (24h)
```
The one key governs both the interactive question wait and the Runtime
approval-decision wait, and the wait is this table's only user-facing clock —
wall-clock and stream protections elsewhere are unaffected.
This key governs question waits only. Approvals have their own clock,
`[approval] timeout_seconds`, which waits indefinitely unless you set it.
Wall-clock and stream protections elsewhere are unaffected.
## Feature Flags