fix(runtime): honor uncapped goals and longer browser sign-in waits

Resolve goal step limits through the shared turn-budget policy: omitted and
zero are uncapped, positive limits still apply and clamp at 100000. Preserve
the public compatibility constant without imposing a hidden 1000-step ceiling.
Allow 15 minutes for MCP and provider browser callbacks while retaining
cancellation, listener, state and issuer validation. Synchronize English and
Chinese configuration docs.

Refs #6512, #6865. Callback-window reference adapted from @asto18089.
Validation at source 499468bbf066: release-issues selector 23/23 PASS,
comprising all 12 goal/OAuth regressions and 11 cache-parser tests. Production
TUI/CLI all-target/all-feature warnings-as-errors Clippy and CLI build PASS.
Full npm gate: package 101, SDK 19, extension 413 passed/7 skipped, dependency
7, web 772 PASS; check:web typecheck and 894-page build PASS, 0 lint errors/
1 warning. Final combined hosted CI remains required before main.

Co-authored-by: asto18089 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Botandasto18089 committed 2026-10-08 02:58:15 -07:00
1 parent 1c82c935c9
commit 3c0a3d2f25
8 files changed
+84 -82

No files matched your search

+6 -6
View File
@@ -32,12 +32,12 @@ use std::time::Duration;
/// with `[goal] max_continuations`; `0` keeps the default unlimited behavior.
pub const DEFAULT_MAX_GOAL_CONTINUATIONS: u32 = 0;
/// Default per-engine-turn step allowance while a goal is active (#5994).
/// Deliberate maintainer policy, not a measured number: five times the
/// ordinary interactive allowance (200), so intentional goal work has room
/// while every provider turn stays finite. The count of continuation passes
/// remains governed separately (`[goal] max_continuations`).
pub const DEFAULT_GOAL_MAX_STEPS: u32 = 1_000;
/// Compatibility representation of an uncapped goal turn (#6512).
///
/// Retained for users of the public runtime API. Hosts resolve raw goal step
/// configuration through the engine's shared model-step budget resolver;
/// this constant does not establish a separate goal budget policy.
pub const DEFAULT_GOAL_MAX_STEPS: u32 = u32::MAX;
/// How many consecutive critical `not_achieved` reviews naming the *same*
/// normalized gap set a goal may accumulate before it pauses itself with
+14 -18
View File
@@ -1390,12 +1390,10 @@ pub struct GoalConfig {
#[serde(default)]
pub max_continuations: Option<u32>,
/// Per-engine-turn step allowance while a goal is active (#5994). Goal
/// work gets a larger but still finite budget than an ordinary
/// interactive turn: `None` or `0` resolves to
/// [`crate::goal_loop::DEFAULT_GOAL_MAX_STEPS`] (1,000); values clamp to
/// `1..=100,000`. This bounds each turn, never the number of
/// continuation passes; explicit per-invocation ceilings
/// Optional per-engine-turn step ceiling while a goal is active (#6512).
/// Like ordinary turns, `None` or `0` leaves model steps uncapped;
/// positive values clamp to `1..=100,000`. This bounds each turn, never
/// the number of continuation passes; explicit per-invocation ceilings
/// (`exec --max-turns`, child-worker caps) still win.
#[serde(default)]
pub max_steps: Option<u32>,
@@ -7048,22 +7046,20 @@ impl Config {
.unwrap_or(crate::goal_loop::DEFAULT_MAX_GOAL_CONTINUATIONS)
}
/// Per-engine-turn step allowance while a goal is active (#5994). Goal
/// turns get [`crate::goal_loop::DEFAULT_GOAL_MAX_STEPS`] by default —
/// five times the ordinary interactive allowance — while staying finite.
/// Per-engine-turn model-step budget while a goal is active (#6512).
/// Uses the ordinary turn resolver: omitted or zero is uncapped, and
/// explicit positive ceilings are clamped to its supported range.
#[must_use]
pub fn goal_max_steps(&self) -> u32 {
let configured = self.goal.as_ref().and_then(|goal| goal.max_steps);
match configured {
None | Some(0) => crate::goal_loop::DEFAULT_GOAL_MAX_STEPS,
Some(steps) => {
let clamped = steps.clamp(1, 100_000);
if clamped != steps {
tracing::warn!("[goal] max_steps={steps} out of range; clamping to {clamped}");
}
clamped
}
let resolved = crate::core::engine::turn_budget::resolve_max_model_steps(configured);
if let Some(steps) = configured
&& steps > 0
&& resolved != steps
{
tracing::warn!("[goal] max_steps={steps} out of range; clamping to {resolved}");
}
resolved
}
/// Whether a goal's `token_budget` is a hard stop (#6013). Default `false`
+42 -34
View File
@@ -2075,43 +2075,51 @@ fn user_input_limits_read_from_tools_table_and_clamp() {
}
#[test]
fn goal_max_steps_resolves_default_zero_and_clamps() {
let parsed: ConfigFile = toml::from_str("").expect("empty config");
assert_eq!(
parsed.base.goal_max_steps(),
crate::goal_loop::DEFAULT_GOAL_MAX_STEPS
);
fn goal_max_steps_default_and_zero_are_uncapped() {
for source in ["", "[goal]", "[goal]\nmax_steps = 0\n"] {
let parsed: ConfigFile = toml::from_str(source).expect("goal config");
let resolved = parsed.base.goal_max_steps();
assert_eq!(resolved, u32::MAX, "{source:?}");
// An explicit 0 is the goal default (1,000), never unlimited.
let parsed: ConfigFile = toml::from_str(
r#"
[goal]
max_steps = 0
"#,
)
.expect("goal config");
assert_eq!(
parsed.base.goal_max_steps(),
crate::goal_loop::DEFAULT_GOAL_MAX_STEPS
);
// Hosts pass this resolved value directly to the goal turn. It must
// remain uncapped beyond the old 1,000-step ceiling and at saturation.
let mut turn = crate::core::turn::TurnContext::with_budget_source(
resolved,
crate::core::turn::StepBudgetSource::Goal,
);
assert_eq!(turn.step_limit(), None);
turn.step = 1_000;
assert!(turn.next_step(), "{source:?}");
assert!(!turn.at_max_steps(), "{source:?}");
turn.step = u32::MAX;
assert!(turn.next_step(), "{source:?}");
assert!(!turn.at_max_steps(), "{source:?}");
}
}
let parsed: ConfigFile = toml::from_str(
r#"
[goal]
max_steps = 50
"#,
)
.expect("goal config");
assert_eq!(parsed.base.goal_max_steps(), 50);
#[test]
fn goal_max_steps_preserves_positive_limits_and_clamps() {
for (configured, expected) in [
(1, 1),
(50, 50),
(100_000, 100_000),
(500_000, 100_000),
(u32::MAX, 100_000),
] {
let source = format!("[goal]\nmax_steps = {configured}\n");
let parsed: ConfigFile = toml::from_str(&source).expect("goal config");
let resolved = parsed.base.goal_max_steps();
assert_eq!(resolved, expected, "{source:?}");
let parsed: ConfigFile = toml::from_str(
r#"
[goal]
max_steps = 500000
"#,
)
.expect("goal config");
assert_eq!(parsed.base.goal_max_steps(), 100_000);
let mut turn = crate::core::turn::TurnContext::with_budget_source(
resolved,
crate::core::turn::StepBudgetSource::Goal,
);
assert_eq!(turn.step_limit(), Some(expected));
turn.step = expected;
assert!(turn.at_max_steps(), "{source:?}");
assert!(!turn.next_step(), "{source:?}");
}
}
#[test]
+4 -3
View File
@@ -1236,7 +1236,7 @@ enum OAuthLoginAnnounce {
///
/// The authorization URL is available immediately so the model can relay it
/// to the user verbatim; [`McpOAuthToolLogin::finish`] then blocks on the
/// loopback callback (up to 5 minutes, same as `/mcp login`) and persists
/// loopback callback (up to 15 minutes, same as `/mcp login`) and persists
/// the issued tokens to the shared store on success.
pub struct McpOAuthToolLogin {
server_name: String,
@@ -1374,7 +1374,7 @@ Codewhale client, and the exact authorization URL is shown to the user in \
the session status while this call waits. The same URL is returned in this \
call's result; if the user reports the browser did not open, show that URL \
to the user verbatim and ask them to complete the sign-in there.\n\
2. The call blocks (up to 5 minutes) until the browser flow completes on the \
2. The call blocks (up to 15 minutes) until the browser flow completes on the \
local callback listener, is declined, or times out. Do not assume success \
before the call returns.\n\
3. On success the server reconnects and its real MCP tools replace this \
@@ -1718,7 +1718,8 @@ impl OauthLoginFlow {
}
let result = async {
let callback = timeout(Duration::from_secs(300), &mut self.rx)
// Allow time for the user to finish browser authentication and 2FA.
let callback = timeout(Duration::from_secs(900), &mut self.rx)
.await
.with_context(|| {
let retry_hint = match announce {
+1 -1
View File
@@ -7782,7 +7782,7 @@ async fn needs_auth_server_advertises_synthetic_authenticate_tool() {
auth_tool.description
);
assert!(
auth_tool.description.contains("blocks (up to 5 minutes)"),
auth_tool.description.contains("blocks (up to 15 minutes)"),
"{}",
auth_tool.description
);
+2 -1
View File
@@ -2000,7 +2000,8 @@ pub(crate) fn start_auth_request_on(
})
}
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
// Allow time for browser authentication and 2FA, as in the MCP OAuth flow.
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(900);
const CALLBACK_HTML_OK: &str = "<!doctype html><html><body><p>Signed in to Codewhale. You can close this tab.</p></body></html>";
const CALLBACK_HTML_ERR: &str = "<!doctype html><html><body><p>Sign-in did not complete. You can close this tab and retry in Codewhale.</p></body></html>";
+9 -11
View File
@@ -2647,7 +2647,7 @@ reasoning contract, and all four membership ids omit generic sampling fields.
- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs <seconds>` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.
- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.
- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (default `1000`); see the Goal loop section below.
- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.
- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.
- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.
- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.
@@ -2775,12 +2775,10 @@ max_continuations = 100
# provider turn open. Default: 0 (continue immediately).
continuation_delay_seconds = 300
# Per-turn step allowance while a goal is active (#5994). Goal turns get a
# larger but still finite budget than an ordinary interactive turn.
# Default: 1000 (0 or absent resolves to 1000, never unlimited). Range:
# 1..=100,000. This bounds each provider turn, never the number of
# continuation passes.
max_steps = 1000
# Optional per-turn model-step ceiling while a goal is active (#6512).
# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.
# This bounds each provider turn, never the number of continuation passes.
max_steps = 0
```
The effective delay is capped at 86,400 seconds (24 hours); use an automation
@@ -2790,11 +2788,11 @@ When an explicit backstop fires, the goal pauses with a status message naming
`[goal] max_continuations` and a warning is logged; resume the goal after
inspecting progress, or raise/disable the backstop.
`[goal] max_steps` governs one engine turn at a time: the ordinary interactive
turn has no implicit model-step ceiling. Explicit per-invocation
`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive
turns, omitted or `0` leaves model steps uncapped. Explicit per-invocation
ceilings — `exec --max-turns N`, child-worker caps — always win over it. At
about 80% of the selected budget the model is told to land; at exhaustion it
gets one bounded final report and the turn classifies as budget-exhausted. An
about 80% of an explicit positive budget the model is told to land; at exhaustion
it gets one bounded final report and the turn classifies as budget-exhausted. An
unfinished goal then pauses with the BudgetLimit reason instead of re-arming
another goal turn — resume it explicitly after reviewing the report. Wall-clock
and stream protections are separate and still apply.
+6 -8
View File
@@ -1383,7 +1383,7 @@ DeepSeek V4 前缀缓存让 token 标签变得重要。这些数量保持分离
- `tui.osc8_links`(bool,可选,macOS/Linux 默认开启,Windows 默认关闭):在转录输出的 URL 周围发出 OSC 8 转义序列,这样支持的终端(iTerm2、Terminal.app 13+、Ghostty、Kitty、WezTerm、Alacritty、较新的 gnome-terminal/konsole)可以用终端的链接手势打开它们——通常是 macOS 上的 Cmd-click,Linux/Windows 上的 Ctrl-click。没有 OSC 8 支持的终端渲染普通标签并忽略转义。转义带外发出(不在缓冲区单元格内),所以列损坏不是问题;只在终端错误渲染 OSC 8 终止符本身时设 `false`。Windows 旧控制台默认关闭;用 `true` 选择加入。
- `tui.max_model_steps`(int,可选,默认不设上限):单个普通回合的可选模型步数上限。省略或为 `0` 表示模型步数不设上限;显式的正值会被钳制到 `1..=100000`。无头 `exec` 和 Fleet worker 同样没有隐含的模型步数上限;`exec --max-turns N` 和正数的 worker 预算仍然适用。当用完显式步数预算的约 80% 时,模型会收到一次软着陆通知;步数耗尽时,回合以 `Failed` 结束,信息为 `Maximum model steps reached before completion (limit: N)`,必要时会先有一次有界的最终报告响应。累计的墙钟时间和逐流的限制保持独立。处于活动状态的交互式目标回合改用 `goal.max_steps`(默认 `1000`);见下文的目标循环一节。
- `tui.max_model_steps`(int,可选,默认不设上限):单个普通回合的可选模型步数上限。省略或为 `0` 表示模型步数不设上限;显式的正值会被钳制到 `1..=100000`。无头 `exec` 和 Fleet worker 同样没有隐含的模型步数上限;`exec --max-turns N` 和正数的 worker 预算仍然适用。当用完显式步数预算的约 80% 时,模型会收到一次软着陆通知;步数耗尽时,回合以 `Failed` 结束,信息为 `Maximum model steps reached before completion (limit: N)`,必要时会先有一次有界的最终报告响应。累计的墙钟时间和逐流的限制保持独立。处于活动状态的交互式目标回合改用 `goal.max_steps`(同样默认不设上限);见下文的目标循环一节。
- `tui.turn_wall_clock_secs`(int,可选,默认不设限):每个回合累计的墙钟预算,单位为秒,跨一个回合的每个模型步骤度量(不是按请求)。等待人工审批所阻塞的时间不计入。省略或为 `0` 表示不设限;正值会钳制到 `30..=86400`(上限为 24 小时)。耗尽时,回合会在授权下一次计费请求之前停止,并给出一条指明该限制及要调高的键的信息。
@@ -1497,19 +1497,17 @@ max_continuations = 100
# provider turn open. Default: 0 (continue immediately).
continuation_delay_seconds = 300
# Per-turn step allowance while a goal is active (#5994). Goal turns get a
# larger but still finite budget than an ordinary interactive turn.
# Default: 1000 (0 or absent resolves to 1000, never unlimited). Range:
# 1..=100,000. This bounds each provider turn, never the number of
# continuation passes.
max_steps = 1000
# Optional per-turn model-step ceiling while a goal is active (#6512).
# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.
# This bounds each provider turn, never the number of continuation passes.
max_steps = 0
```
有效延迟上限为 86,400 秒(24 小时);比每天一次更不频繁的调度,请使用自动化。
显式的兜底触发时,目标会以一条点名 `[goal] max_continuations` 的状态消息暂停,并记录一条警告;检查进度后再恢复目标,或者调高/禁用该兜底。
`[goal] max_steps` 一次只约束一个引擎回合:普通的交互式回合没有隐含的模型步数上限。显式的逐次调用上限——`exec --max-turns N`、子 worker 的上限——始终优先于它。当所选预算用到约 80% 时,会告知模型着陆;耗尽时,它会得到一次有界的最终报告,并且该回合被归类为预算耗尽。未完成的目标随后会以 BudgetLimit 原因暂停,而不是重新启动下一个目标回合——请在审阅报告之后显式恢复它。墙钟时间和流保护是独立的,仍然适用。
`[goal] max_steps` 一次只约束一个引擎回合。与普通的交互式回合一样,省略或设为 `0` 表示模型步数不设上限。显式的逐次调用上限——`exec --max-turns N`、子 worker 的上限——始终优先于它。当显式正数预算用到约 80% 时,会告知模型着陆;耗尽时,它会得到一次有界的最终报告,并且该回合被归类为预算耗尽。未完成的目标随后会以 BudgetLimit 原因暂停,而不是重新启动下一个目标回合——请在审阅报告之后显式恢复它。墙钟时间和流保护是独立的,仍然适用。
延迟只在显式创建的目标仍然活动、且在一个成功回合之后才开始。`/goal pause`、`/goal done`、`/goal blocked`、`/goal clear`、Esc 或 Ctrl+C 会在下一次 provider 请求开始之前取消待定的继续。失败的回合和策略/路由失败绝不会安排下一个回合。配置中只存储数字形式的节奏;该循环不会持久化任何提示、凭据或机密。