fix(release): stabilize v0.8.52
This commit is contained in:
+46
-1
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.52] - 2026-06-03
|
||||
|
||||
### Added
|
||||
|
||||
- **SiliconFlow China region provider.** Added the `siliconflow-CN` provider
|
||||
variant for the China regional endpoint, sharing the existing
|
||||
`[providers.siliconflow]` credentials and `SILICONFLOW_API_KEY` slot
|
||||
instead of creating a second credential namespace (#2588, #2615).
|
||||
- **Multimodal `/attach` image forwarding.** Attached images are now sent as
|
||||
OpenAI-compatible `image_url` content blocks so multimodal providers can
|
||||
actually see image attachments (#2584, #2587, #2607).
|
||||
- **Sub-agent lifecycle hooks and runtime metadata.** Sub-agent spawn/complete
|
||||
hook events, mode-change runtime messages, mode metadata on turns, localized
|
||||
context-inspector strings, and drag-to-resize sidebar width are included in
|
||||
this release slice.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sub-agents now auto-cancel after stale heartbeats.** Running sub-agents
|
||||
track manager-visible progress and are auto-cancelled after the configurable
|
||||
`[subagents] heartbeat_timeout_secs` window (default 300s), releasing their
|
||||
concurrency slot and unblocking parent turns that would otherwise wait
|
||||
forever (#2603, #2614, #2620).
|
||||
- **SiliconFlow-CN no longer breaks main.** Filled the missing CLI provider
|
||||
exhaustiveness arms and removed the duplicate/unreachable TUI config arms
|
||||
left by the #2615 landing; direct auth now stores the China-region variant in
|
||||
the shared SiliconFlow provider table (#2616, #2618, #2619).
|
||||
- **v0.8.51 image-attach closure corrected.** The `/attach` multimodal fix
|
||||
landed after the v0.8.51 tag, so this release is the first version that
|
||||
actually contains it for users installing from the published release line
|
||||
(#2584, #2607).
|
||||
- **Legacy SSE MCP reconnects are retryable again.** Closed or reset
|
||||
`POST /messages` requests on stale legacy SSE sessions now trigger the same
|
||||
reconnect-and-retry path as closed SSE streams, removing a release-gate flake
|
||||
and matching the intended recovery behavior (#2597).
|
||||
|
||||
### Community
|
||||
|
||||
Thanks to **@xyuai** (#2587), **@IcedOranges** (#2584), **@BH8GCJ** (#2588),
|
||||
**@shenjackyuanjie** (#2618, #2619), **@idling11** (#2606, #2616),
|
||||
**@AresNing** (#2578), **@gordonlu**, **@encyc**, and **@simuusang** (#2603,
|
||||
#2620) for reports, patches, retesting, and release-stabilization signals that
|
||||
shaped this pass.
|
||||
|
||||
## [0.8.51] - 2026-06-02
|
||||
|
||||
### Added
|
||||
@@ -5329,7 +5373,8 @@ Welcome — and thank you.
|
||||
- Hooks system and config profiles
|
||||
- Example skills and launch assets
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.52...HEAD
|
||||
[0.8.52]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...v0.8.52
|
||||
[0.8.51]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...v0.8.51
|
||||
[0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50
|
||||
[0.8.49]: https://github.com/Hmbown/CodeWhale/compare/v0.8.48...v0.8.49
|
||||
|
||||
Generated
+15
-15
@@ -803,7 +803,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21"
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-agent"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"codewhale-config",
|
||||
"serde",
|
||||
@@ -811,7 +811,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-app-server"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -836,7 +836,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-cli"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -863,7 +863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-config"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codewhale-execpolicy",
|
||||
@@ -877,7 +877,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-core"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -895,7 +895,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-execpolicy"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codewhale-protocol",
|
||||
@@ -904,7 +904,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-hooks"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -918,7 +918,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-mcp"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -927,7 +927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-protocol"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -935,7 +935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-release"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"reqwest",
|
||||
@@ -946,7 +946,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-secrets"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"keyring",
|
||||
@@ -959,7 +959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-state"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -971,7 +971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-tools"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -985,7 +985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-tui"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -1054,7 +1054,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-tui-core"
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.51"
|
||||
version = "0.8.52"
|
||||
edition = "2024"
|
||||
# Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the
|
||||
# codebase relies on extensively. Cargo enforces this so users on older
|
||||
|
||||
@@ -7,5 +7,5 @@ repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
codewhale-config = { path = "../config", version = "0.8.51" }
|
||||
codewhale-config = { path = "../config", version = "0.8.52" }
|
||||
serde.workspace = true
|
||||
|
||||
@@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.8.51" }
|
||||
codewhale-config = { path = "../config", version = "0.8.51" }
|
||||
codewhale-core = { path = "../core", version = "0.8.51" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.51" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.51" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.51" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.51" }
|
||||
codewhale-state = { path = "../state", version = "0.8.51" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.51" }
|
||||
codewhale-agent = { path = "../agent", version = "0.8.52" }
|
||||
codewhale-config = { path = "../config", version = "0.8.52" }
|
||||
codewhale-core = { path = "../core", version = "0.8.52" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.52" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.52" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.52" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.52" }
|
||||
codewhale-state = { path = "../state", version = "0.8.52" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.52" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -25,14 +25,14 @@ path = "src/bin/deepseek_legacy_shim.rs"
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.8.51" }
|
||||
codewhale-app-server = { path = "../app-server", version = "0.8.51" }
|
||||
codewhale-config = { path = "../config", version = "0.8.51" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.51" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.51" }
|
||||
codewhale-release = { path = "../release", version = "0.8.51" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.51" }
|
||||
codewhale-state = { path = "../state", version = "0.8.51" }
|
||||
codewhale-agent = { path = "../agent", version = "0.8.52" }
|
||||
codewhale-app-server = { path = "../app-server", version = "0.8.52" }
|
||||
codewhale-config = { path = "../config", version = "0.8.52" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.52" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.52" }
|
||||
codewhale-release = { path = "../release", version = "0.8.52" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.52" }
|
||||
codewhale-state = { path = "../state", version = "0.8.52" }
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -744,6 +744,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str {
|
||||
ProviderKind::Novita => "novita",
|
||||
ProviderKind::Fireworks => "fireworks",
|
||||
ProviderKind::Siliconflow => "siliconflow",
|
||||
ProviderKind::SiliconflowCN => "siliconflow",
|
||||
ProviderKind::Arcee => "arcee",
|
||||
ProviderKind::Moonshot => "moonshot",
|
||||
ProviderKind::Sglang => "sglang",
|
||||
@@ -753,7 +754,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str {
|
||||
}
|
||||
|
||||
/// Provider order used by the `auth list` and `auth status` outputs.
|
||||
const PROVIDER_LIST: [ProviderKind; 16] = [
|
||||
const PROVIDER_LIST: [ProviderKind; 17] = [
|
||||
ProviderKind::Deepseek,
|
||||
ProviderKind::NvidiaNim,
|
||||
ProviderKind::Openai,
|
||||
@@ -765,6 +766,7 @@ const PROVIDER_LIST: [ProviderKind; 16] = [
|
||||
ProviderKind::Novita,
|
||||
ProviderKind::Fireworks,
|
||||
ProviderKind::Siliconflow,
|
||||
ProviderKind::SiliconflowCN,
|
||||
ProviderKind::Arcee,
|
||||
ProviderKind::Moonshot,
|
||||
ProviderKind::Sglang,
|
||||
@@ -822,6 +824,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
|
||||
ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
|
||||
ProviderKind::Fireworks => &["FIREWORKS_API_KEY"],
|
||||
ProviderKind::Siliconflow => &["SILICONFLOW_API_KEY"],
|
||||
ProviderKind::SiliconflowCN => &["SILICONFLOW_API_KEY"],
|
||||
ProviderKind::Arcee => &["ARCEE_API_KEY"],
|
||||
ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
|
||||
ProviderKind::Sglang => &["SGLANG_API_KEY"],
|
||||
|
||||
@@ -8,8 +8,8 @@ description = "Config schema and precedence model for DeepSeek workspace archite
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.51" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.51" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.52" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.52" }
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -2465,7 +2465,9 @@ impl EnvRuntimeOverrides {
|
||||
ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
|
||||
ProviderKind::Novita => self.novita_base_url.clone(),
|
||||
ProviderKind::Fireworks => self.fireworks_base_url.clone(),
|
||||
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => self.siliconflow_base_url.clone(),
|
||||
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
|
||||
self.siliconflow_base_url.clone()
|
||||
}
|
||||
ProviderKind::Arcee => self.arcee_base_url.clone(),
|
||||
ProviderKind::Moonshot => self.moonshot_base_url.clone(),
|
||||
ProviderKind::Sglang => self.sglang_base_url.clone(),
|
||||
@@ -2478,7 +2480,9 @@ impl EnvRuntimeOverrides {
|
||||
let model = match provider {
|
||||
ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
|
||||
ProviderKind::Volcengine => self.volcengine_model.clone(),
|
||||
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => self.siliconflow_model.clone(),
|
||||
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
|
||||
self.siliconflow_model.clone()
|
||||
}
|
||||
ProviderKind::Arcee => self.arcee_model.clone(),
|
||||
ProviderKind::Moonshot => self.moonshot_model.clone(),
|
||||
ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
|
||||
|
||||
@@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.8.51" }
|
||||
codewhale-config = { path = "../config", version = "0.8.51" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.51" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.51" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.51" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.51" }
|
||||
codewhale-state = { path = "../state", version = "0.8.51" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.51" }
|
||||
codewhale-agent = { path = "../agent", version = "0.8.52" }
|
||||
codewhale-config = { path = "../config", version = "0.8.52" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.52" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.52" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.52" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.52" }
|
||||
codewhale-state = { path = "../state", version = "0.8.52" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.52" }
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.51" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.52" }
|
||||
serde.workspace = true
|
||||
|
||||
@@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.51" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.52" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -681,7 +681,7 @@ impl Secrets {
|
||||
/// | `novita` | `NOVITA_API_KEY` |
|
||||
/// | `nvidia` / `nvidia-nim` / `nim` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, `DEEPSEEK_API_KEY` |
|
||||
/// | `fireworks` | `FIREWORKS_API_KEY` |
|
||||
/// | `siliconflow` | `SILICONFLOW_API_KEY` |
|
||||
/// | `siliconflow` / `siliconflow-cn` | `SILICONFLOW_API_KEY` |
|
||||
/// | `arcee` / `arcee-ai` | `ARCEE_API_KEY` |
|
||||
/// | `moonshot` / `kimi` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |
|
||||
/// | `sglang` | `SGLANG_API_KEY` |
|
||||
@@ -710,7 +710,8 @@ pub fn env_for(name: &str) -> Option<String> {
|
||||
&["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
|
||||
}
|
||||
"fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
|
||||
"siliconflow" | "silicon-flow" | "silicon_flow" => &["SILICONFLOW_API_KEY"],
|
||||
"siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
|
||||
| "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
|
||||
"arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
|
||||
"moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
|
||||
"sglang" | "sg-lang" => &["SGLANG_API_KEY"],
|
||||
@@ -1144,6 +1145,8 @@ mod tests {
|
||||
assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
|
||||
assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
|
||||
assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
|
||||
assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
|
||||
assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.51" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.52" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
+75
-5
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.52] - 2026-06-03
|
||||
|
||||
### Added
|
||||
|
||||
- **SiliconFlow China region provider.** Added the `siliconflow-CN` provider
|
||||
variant for the China regional endpoint, sharing the existing
|
||||
`[providers.siliconflow]` credentials and `SILICONFLOW_API_KEY` slot
|
||||
instead of creating a second credential namespace (#2588, #2615).
|
||||
- **Multimodal `/attach` image forwarding.** Attached images are now sent as
|
||||
OpenAI-compatible `image_url` content blocks so multimodal providers can
|
||||
actually see image attachments (#2584, #2587, #2607).
|
||||
- **Sub-agent lifecycle hooks and runtime metadata.** Sub-agent spawn/complete
|
||||
hook events, mode-change runtime messages, mode metadata on turns, localized
|
||||
context-inspector strings, and drag-to-resize sidebar width are included in
|
||||
this release slice.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sub-agents now auto-cancel after stale heartbeats.** Running sub-agents
|
||||
track manager-visible progress and are auto-cancelled after the configurable
|
||||
`[subagents] heartbeat_timeout_secs` window (default 300s), releasing their
|
||||
concurrency slot and unblocking parent turns that would otherwise wait
|
||||
forever (#2603, #2614, #2620).
|
||||
- **SiliconFlow-CN no longer breaks main.** Filled the missing CLI provider
|
||||
exhaustiveness arms and removed the duplicate/unreachable TUI config arms
|
||||
left by the #2615 landing; direct auth now stores the China-region variant in
|
||||
the shared SiliconFlow provider table (#2616, #2618, #2619).
|
||||
- **v0.8.51 image-attach closure corrected.** The `/attach` multimodal fix
|
||||
landed after the v0.8.51 tag, so this release is the first version that
|
||||
actually contains it for users installing from the published release line
|
||||
(#2584, #2607).
|
||||
- **Legacy SSE MCP reconnects are retryable again.** Closed or reset
|
||||
`POST /messages` requests on stale legacy SSE sessions now trigger the same
|
||||
reconnect-and-retry path as closed SSE streams, removing a release-gate flake
|
||||
and matching the intended recovery behavior (#2597).
|
||||
|
||||
### Community
|
||||
|
||||
Thanks to **@xyuai** (#2587), **@IcedOranges** (#2584), **@BH8GCJ** (#2588),
|
||||
**@shenjackyuanjie** (#2618, #2619), **@idling11** (#2606, #2616),
|
||||
**@AresNing** (#2578), **@gordonlu**, **@encyc**, and **@simuusang** (#2603,
|
||||
#2620) for reports, patches, retesting, and release-stabilization signals that
|
||||
shaped this pass.
|
||||
|
||||
## [0.8.51] - 2026-06-02
|
||||
|
||||
### Added
|
||||
@@ -66,10 +110,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Assistant turns no longer leave an orphaned role glyph (the stray "blue dot")
|
||||
when a turn streams only whitespace between reasoning and a tool call.
|
||||
- Scrolling the mouse wheel over the right-hand sidebar no longer leaks into
|
||||
the transcript scroll.
|
||||
- The sidebar hover tooltip now appears only for truncated lines, sits below
|
||||
the cursor, and uses a neutral surface color instead of the warning-orange
|
||||
- Scrolling the mouse wheel over the right-hand sidebar no longer leaks into the
|
||||
transcript scroll.
|
||||
- The sidebar hover tooltip now appears only for truncated lines, sits below the
|
||||
cursor, and uses a neutral surface color instead of the warning-orange
|
||||
highlight that overlapped neighbouring rows.
|
||||
- Corrected the README's description of the Constitution (Article VII is the
|
||||
hierarchy itself; Article II's truth duty overrides even a user request) to
|
||||
@@ -77,6 +121,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Repaired release-blocking unit and integration tests left failing by the
|
||||
cycle-removal and compaction-threshold refactors (relay instruction,
|
||||
model-reject message, compaction budget, mock-LLM threshold helper).
|
||||
- Fixed DEC private-mode CSI fragment leakage into composer text after
|
||||
terminal resets, restoring clean prompt editing (#2592).
|
||||
- The engine now recovers from turn-level panics instead of killing the
|
||||
main event loop, keeping the session alive through transient failures
|
||||
(#2583, #1269).
|
||||
- Deeply nested files are now discoverable via @-mention and Ctrl+P file
|
||||
picker; the default walk depth was relaxed to handle monorepo layouts (#2488).
|
||||
- Command-palette selection stays visible when scrolling through long lists
|
||||
instead of scrolling off-screen (#2590).
|
||||
- exec_shell child processes now inherit .NET/NuGet and Windows app-data
|
||||
environment variables, fixing toolchain resolution on Windows (#1857).
|
||||
- A warning is emitted when shell/sandbox config keys are nested under
|
||||
unknown top-level sections instead of being silently ignored (#2589).
|
||||
- Diff-render now preserves leading whitespace in patch content lines,
|
||||
fixing an extra-space regression in PR previews (#2591). Thanks @zlh124.
|
||||
- Model selection from the /model command now persists per-provider across
|
||||
restarts, with a warning when persistence fails.
|
||||
|
||||
### Community
|
||||
|
||||
Thanks to **@zlh124** (#2591) and **@reidliu41** (#2601) for the fixes
|
||||
harvested into this release. Thanks also to **@idling11** (#2602),
|
||||
**@gordonlu** (#2585), **@cyq1017** (#2593), **@xyuai** (#2587, #2584),
|
||||
and **@IcedOranges** (#2584) for reports, drafts, and investigations
|
||||
that shaped this release cycle.
|
||||
|
||||
## [0.8.50] - 2026-06-02
|
||||
|
||||
@@ -5304,7 +5373,8 @@ Welcome — and thank you.
|
||||
- Hooks system and config profiles
|
||||
- Example skills and launch assets
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.52...HEAD
|
||||
[0.8.52]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...v0.8.52
|
||||
[0.8.51]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...v0.8.51
|
||||
[0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50
|
||||
[0.8.49]: https://github.com/Hmbown/CodeWhale/compare/v0.8.48...v0.8.49
|
||||
|
||||
@@ -27,11 +27,11 @@ path = "src/bin/deepseek_tui_legacy_shim.rs"
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
codewhale-config = { path = "../config", version = "0.8.51" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.51" }
|
||||
codewhale-release = { path = "../release", version = "0.8.51" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.51" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.51" }
|
||||
codewhale-config = { path = "../config", version = "0.8.52" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.52" }
|
||||
codewhale-release = { path = "../release", version = "0.8.52" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.52" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.52" }
|
||||
schemaui = { version = "0.12.0", default-features = false, optional = true }
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -1092,7 +1092,8 @@ pub(super) fn apply_reasoning_effort(
|
||||
| ApiProvider::Openrouter
|
||||
| ApiProvider::XiaomiMimo
|
||||
| ApiProvider::Novita
|
||||
| ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
|
||||
| ApiProvider::Siliconflow
|
||||
| ApiProvider::SiliconflowCn
|
||||
| ApiProvider::Sglang
|
||||
| ApiProvider::Volcengine => {
|
||||
body["thinking"] = json!({ "type": "disabled" });
|
||||
@@ -1128,7 +1129,8 @@ pub(super) fn apply_reasoning_effort(
|
||||
// DeepSeek compatibility: low/medium both map to high
|
||||
ApiProvider::Deepseek
|
||||
| ApiProvider::DeepseekCN
|
||||
| ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
|
||||
| ApiProvider::Siliconflow
|
||||
| ApiProvider::SiliconflowCn
|
||||
| ApiProvider::Sglang
|
||||
| ApiProvider::Volcengine => {
|
||||
body["reasoning_effort"] = json!("high");
|
||||
@@ -1189,7 +1191,8 @@ pub(super) fn apply_reasoning_effort(
|
||||
"xhigh" | "max" | "highest" => match provider {
|
||||
ApiProvider::Deepseek
|
||||
| ApiProvider::DeepseekCN
|
||||
| ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
|
||||
| ApiProvider::Siliconflow
|
||||
| ApiProvider::SiliconflowCn
|
||||
| ApiProvider::Sglang
|
||||
| ApiProvider::Volcengine => {
|
||||
body["reasoning_effort"] = json!("max");
|
||||
|
||||
+162
-27
@@ -32,6 +32,13 @@ pub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1;
|
||||
/// keeps a misconfigured per-step timeout from masking real model/network
|
||||
/// hangs forever.
|
||||
pub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800;
|
||||
/// Default wall-clock interval without manager-visible sub-agent progress
|
||||
/// before a running child can be auto-cancelled to release its slot (#2614).
|
||||
pub const DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 300;
|
||||
/// Minimum accepted `[subagents] heartbeat_timeout_secs`.
|
||||
pub const MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 30;
|
||||
/// Maximum accepted `[subagents] heartbeat_timeout_secs` (1 hour).
|
||||
pub const MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 3600;
|
||||
pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro";
|
||||
pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
|
||||
pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
|
||||
@@ -178,10 +185,10 @@ impl ApiProvider {
|
||||
"novita" => Some(Self::Novita),
|
||||
"fireworks" | "fireworks-ai" => Some(Self::Fireworks),
|
||||
"siliconflow" | "silicon-flow" | "silicon_flow" => Some(Self::Siliconflow),
|
||||
"siliconflow-cn" | "siliconflow-CN"
|
||||
| "silicon-flow-cn" | "silicon-flow-CN"
|
||||
| "silicon_flow_cn" | "silicon_flow_CN"
|
||||
| "siliconflow-china" => Some(Self::SiliconflowCn),
|
||||
"siliconflow-cn" | "siliconflow-CN" | "silicon-flow-cn" | "silicon-flow-CN"
|
||||
| "silicon_flow_cn" | "silicon_flow_CN" | "siliconflow-china" => {
|
||||
Some(Self::SiliconflowCn)
|
||||
}
|
||||
"arcee" | "arcee-ai" | "arcee_ai" => Some(Self::Arcee),
|
||||
"moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot),
|
||||
"sglang" | "sg-lang" => Some(Self::Sglang),
|
||||
@@ -697,7 +704,10 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) ->
|
||||
}
|
||||
return Some(canonical.to_string());
|
||||
}
|
||||
if matches!(provider, ApiProvider::Siliconflow | ApiProvider::SiliconflowCn) {
|
||||
if matches!(
|
||||
provider,
|
||||
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
|
||||
) {
|
||||
let provider_model = model_for_provider(provider, normalized.clone());
|
||||
if provider_model != normalized {
|
||||
return Some(provider_model);
|
||||
@@ -1419,6 +1429,12 @@ pub struct SubagentsConfig {
|
||||
/// (1..=1800). Zero or unset uses the legacy 120s default (#1806, #1808).
|
||||
#[serde(default)]
|
||||
pub api_timeout_secs: Option<u64>,
|
||||
/// Wall-clock timeout for a running sub-agent that stops making
|
||||
/// manager-visible progress. Defaults to 5 minutes and is kept above the
|
||||
/// per-step API timeout so slow but legitimate model calls are not
|
||||
/// cancelled before their request timeout can fire (#2614).
|
||||
#[serde(default)]
|
||||
pub heartbeat_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.
|
||||
@@ -2311,7 +2327,8 @@ impl Config {
|
||||
| ApiProvider::XiaomiMimo
|
||||
| ApiProvider::Novita
|
||||
| ApiProvider::Fireworks
|
||||
| ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
|
||||
| ApiProvider::Siliconflow
|
||||
| ApiProvider::SiliconflowCn
|
||||
| ApiProvider::Arcee
|
||||
| ApiProvider::Moonshot
|
||||
| ApiProvider::Sglang
|
||||
@@ -2332,7 +2349,6 @@ impl Config {
|
||||
ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,
|
||||
ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
|
||||
ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
|
||||
ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL,
|
||||
ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL,
|
||||
ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL,
|
||||
ApiProvider::Moonshot => {
|
||||
@@ -2387,8 +2403,7 @@ impl Config {
|
||||
ApiProvider::Novita => "novita",
|
||||
ApiProvider::Fireworks => "fireworks",
|
||||
ApiProvider::Siliconflow => "siliconflow",
|
||||
ApiProvider::SiliconflowCn => "siliconflow-CN",
|
||||
ApiProvider::SiliconflowCn => "siliconflow-CN",
|
||||
ApiProvider::SiliconflowCn => "siliconflow",
|
||||
ApiProvider::Arcee => "arcee",
|
||||
ApiProvider::Moonshot => "moonshot",
|
||||
ApiProvider::Sglang => "sglang",
|
||||
@@ -2658,6 +2673,35 @@ impl Config {
|
||||
raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)
|
||||
}
|
||||
|
||||
/// Resolved no-progress heartbeat timeout for running sub-agents.
|
||||
///
|
||||
/// Reads `[subagents] heartbeat_timeout_secs` and clamps to
|
||||
/// `[MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS]`.
|
||||
/// `None` or `0` resolve to the default 300 seconds. The final value is
|
||||
/// also kept at least 30 seconds above `subagent_api_timeout_secs()` so a
|
||||
/// configured long model request is not pre-empted by heartbeat cleanup.
|
||||
#[must_use]
|
||||
pub fn subagent_heartbeat_timeout_secs(&self) -> u64 {
|
||||
let raw = self
|
||||
.subagents
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.heartbeat_timeout_secs)
|
||||
.unwrap_or(DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS);
|
||||
let configured = if raw == 0 {
|
||||
DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS
|
||||
} else {
|
||||
raw.clamp(
|
||||
MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
|
||||
MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
|
||||
)
|
||||
};
|
||||
let min_for_api = self.subagent_api_timeout_secs().saturating_add(30).clamp(
|
||||
MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
|
||||
MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
|
||||
);
|
||||
configured.max(min_for_api)
|
||||
}
|
||||
|
||||
/// Raw sub-agent model override map. Values are validated at spawn time
|
||||
/// so an invalid role/type model fails before any partial agent spawn.
|
||||
#[must_use]
|
||||
@@ -3303,8 +3347,10 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
.fireworks
|
||||
.base_url = Some(value);
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::Siliconflow | ApiProvider::SiliconflowCn)
|
||||
&& let Ok(value) = std::env::var("SILICONFLOW_BASE_URL")
|
||||
if matches!(
|
||||
config.api_provider(),
|
||||
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
|
||||
) && let Ok(value) = std::env::var("SILICONFLOW_BASE_URL")
|
||||
&& !value.trim().is_empty()
|
||||
{
|
||||
config
|
||||
@@ -3460,8 +3506,10 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
.moonshot
|
||||
.model = Some(value);
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::Siliconflow | ApiProvider::SiliconflowCn)
|
||||
&& let Ok(value) = std::env::var("SILICONFLOW_MODEL")
|
||||
if matches!(
|
||||
config.api_provider(),
|
||||
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
|
||||
) && let Ok(value) = std::env::var("SILICONFLOW_MODEL")
|
||||
&& !value.trim().is_empty()
|
||||
{
|
||||
config
|
||||
@@ -3829,8 +3877,7 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str {
|
||||
ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,
|
||||
ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
|
||||
ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
|
||||
ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL,
|
||||
ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL,
|
||||
ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL,
|
||||
ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL,
|
||||
ApiProvider::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
|
||||
ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,
|
||||
@@ -3841,7 +3888,9 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str {
|
||||
}
|
||||
|
||||
fn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {
|
||||
if (provider == ApiProvider::Siliconflow || provider == ApiProvider::SiliconflowCn) && siliconflow_base_url_is_official(base_url) {
|
||||
if (provider == ApiProvider::Siliconflow || provider == ApiProvider::SiliconflowCn)
|
||||
&& siliconflow_base_url_is_official(base_url)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))
|
||||
@@ -3922,12 +3971,14 @@ fn model_for_provider(provider: ApiProvider, normalized: String) -> String {
|
||||
// Flash not yet available on Fireworks; fall through to normalized name
|
||||
"accounts/fireworks/models/deepseek-v4-flash".to_string()
|
||||
}
|
||||
(ApiProvider::Siliconflow, "deepseek-v4-pro" | "deepseek-reasoner" | "deepseek-r1") => {
|
||||
DEFAULT_SILICONFLOW_MODEL.to_string()
|
||||
}
|
||||
(ApiProvider::Siliconflow, "deepseek-v4-flash" | "deepseek-chat" | "deepseek-v3") => {
|
||||
DEFAULT_SILICONFLOW_FLASH_MODEL.to_string()
|
||||
}
|
||||
(
|
||||
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn,
|
||||
"deepseek-v4-pro" | "deepseek-reasoner" | "deepseek-r1",
|
||||
) => DEFAULT_SILICONFLOW_MODEL.to_string(),
|
||||
(
|
||||
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn,
|
||||
"deepseek-v4-flash" | "deepseek-chat" | "deepseek-v3",
|
||||
) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
|
||||
(ApiProvider::Sglang, "deepseek-v4-pro") => DEFAULT_SGLANG_MODEL.to_string(),
|
||||
(ApiProvider::Sglang, "deepseek-v4-flash") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
|
||||
(ApiProvider::Vllm, "deepseek-v4-pro") => DEFAULT_VLLM_MODEL.to_string(),
|
||||
@@ -4758,8 +4809,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf>
|
||||
ApiProvider::Novita => "novita",
|
||||
ApiProvider::Fireworks => "fireworks",
|
||||
ApiProvider::Siliconflow => "siliconflow",
|
||||
ApiProvider::SiliconflowCn => "siliconflow-CN",
|
||||
ApiProvider::SiliconflowCn => "siliconflow-CN",
|
||||
ApiProvider::SiliconflowCn => "siliconflow",
|
||||
ApiProvider::Arcee => "arcee",
|
||||
ApiProvider::Moonshot => "moonshot",
|
||||
ApiProvider::Sglang => "sglang",
|
||||
@@ -4854,7 +4904,7 @@ fn provider_config_key(provider: ApiProvider) -> Result<&'static str> {
|
||||
ApiProvider::Novita => Ok("novita"),
|
||||
ApiProvider::Fireworks => Ok("fireworks"),
|
||||
ApiProvider::Siliconflow => Ok("siliconflow"),
|
||||
ApiProvider::SiliconflowCn => Ok("siliconflow-CN"),
|
||||
ApiProvider::SiliconflowCn => Ok("siliconflow"),
|
||||
ApiProvider::Arcee => Ok("arcee"),
|
||||
ApiProvider::Moonshot => Ok("moonshot"),
|
||||
ApiProvider::Sglang => Ok("sglang"),
|
||||
@@ -5853,6 +5903,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_heartbeat_timeout_defaults_clamps_and_respects_api_timeout() {
|
||||
assert_eq!(
|
||||
Config::default().subagent_heartbeat_timeout_secs(),
|
||||
DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS
|
||||
);
|
||||
|
||||
let zero = Config {
|
||||
subagents: Some(SubagentsConfig {
|
||||
heartbeat_timeout_secs: Some(0),
|
||||
..SubagentsConfig::default()
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
assert_eq!(
|
||||
zero.subagent_heartbeat_timeout_secs(),
|
||||
DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS
|
||||
);
|
||||
|
||||
let low = Config {
|
||||
subagents: Some(SubagentsConfig {
|
||||
api_timeout_secs: Some(1),
|
||||
heartbeat_timeout_secs: Some(1),
|
||||
..SubagentsConfig::default()
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
assert_eq!(
|
||||
low.subagent_heartbeat_timeout_secs(),
|
||||
MIN_SUBAGENT_API_TIMEOUT_SECS + 30
|
||||
);
|
||||
|
||||
let follows_long_api_timeout = Config {
|
||||
subagents: Some(SubagentsConfig {
|
||||
api_timeout_secs: Some(900),
|
||||
heartbeat_timeout_secs: Some(300),
|
||||
..SubagentsConfig::default()
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
assert_eq!(
|
||||
follows_long_api_timeout.subagent_heartbeat_timeout_secs(),
|
||||
930
|
||||
);
|
||||
|
||||
let high = Config {
|
||||
subagents: Some(SubagentsConfig {
|
||||
heartbeat_timeout_secs: Some(MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS + 60),
|
||||
..SubagentsConfig::default()
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
assert_eq!(
|
||||
high.subagent_heartbeat_timeout_secs(),
|
||||
MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {
|
||||
// `save_api_key` writes to the shared user config file. This
|
||||
@@ -6808,10 +6916,20 @@ api_key = "old-openrouter-key"
|
||||
normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-r1").as_deref(),
|
||||
Some(DEFAULT_SILICONFLOW_MODEL)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_name_for_provider(ApiProvider::SiliconflowCn, "deepseek-reasoner")
|
||||
.as_deref(),
|
||||
Some(DEFAULT_SILICONFLOW_MODEL)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-chat").as_deref(),
|
||||
Some(DEFAULT_SILICONFLOW_FLASH_MODEL)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_name_for_provider(ApiProvider::SiliconflowCn, "deepseek-chat")
|
||||
.as_deref(),
|
||||
Some(DEFAULT_SILICONFLOW_FLASH_MODEL)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-v3").as_deref(),
|
||||
Some(DEFAULT_SILICONFLOW_FLASH_MODEL)
|
||||
@@ -8243,14 +8361,14 @@ model = "arcee-trinity-large-preview"
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("CODEWHALE_PROVIDER", "siliconflow");
|
||||
env::set_var("CODEWHALE_PROVIDER", "siliconflow-CN");
|
||||
env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
|
||||
env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1");
|
||||
env::set_var("SILICONFLOW_MODEL", "deepseek-reasoner");
|
||||
}
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::Siliconflow);
|
||||
assert_eq!(config.api_provider(), ApiProvider::SiliconflowCn);
|
||||
assert_eq!(config.deepseek_api_key()?, "sf-env-key");
|
||||
assert_eq!(config.deepseek_base_url(), "https://api.siliconflow.cn/v1");
|
||||
assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_MODEL);
|
||||
@@ -8922,6 +9040,23 @@ api_key = "moonshot-platform-key"
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("sglang-saved-key")
|
||||
);
|
||||
save_api_key_for(ApiProvider::SiliconflowCn, "sf-cn-saved-key")?;
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
let parsed: toml::Value = toml::from_str(&contents)?;
|
||||
assert_eq!(
|
||||
parsed
|
||||
.get("providers")
|
||||
.and_then(|p| p.get("siliconflow"))
|
||||
.and_then(|t| t.get("api_key"))
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("sf-cn-saved-key")
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.get("providers")
|
||||
.and_then(|p| p.get("siliconflow-CN"))
|
||||
.is_none()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ use crate::tools::spec::RuntimeToolServices;
|
||||
use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult};
|
||||
use crate::tools::subagent::{
|
||||
Mailbox, SharedSubAgentManager, SubAgentCompletion, SubAgentForkContext, SubAgentResult,
|
||||
SubAgentRuntime, SubAgentStatus, SubAgentType, new_shared_subagent_manager,
|
||||
SubAgentRuntime, SubAgentStatus, SubAgentType, new_shared_subagent_manager_with_timeout,
|
||||
resolve_subagent_assignment_route,
|
||||
};
|
||||
use crate::tools::todo::{SharedTodoList, TodoListSnapshot, new_shared_todo_list};
|
||||
@@ -314,6 +314,10 @@ pub struct EngineConfig {
|
||||
/// once at engine construction, then threaded onto every
|
||||
/// `SubAgentRuntime` the engine builds (#1806, #1808).
|
||||
pub subagent_api_timeout: Duration,
|
||||
/// No-progress heartbeat timeout for live sub-agents. Used by the manager
|
||||
/// and parent wait loop to auto-cancel stuck children before they exhaust
|
||||
/// the sub-agent slot pool indefinitely (#2614).
|
||||
pub subagent_heartbeat_timeout: Duration,
|
||||
/// Native tools that should stay in the model-visible catalog even when
|
||||
/// they are outside the small default core surface (#2076).
|
||||
pub tools_always_load: HashSet<String>,
|
||||
@@ -372,6 +376,9 @@ impl Default for EngineConfig {
|
||||
subagent_api_timeout: Duration::from_secs(
|
||||
crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS,
|
||||
),
|
||||
subagent_heartbeat_timeout: Duration::from_secs(
|
||||
crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
|
||||
),
|
||||
tools_always_load: HashSet::new(),
|
||||
prefer_bwrap: false,
|
||||
tools: None,
|
||||
@@ -642,8 +649,11 @@ impl Engine {
|
||||
crate::prefix_cache::PrefixStabilityManager::new_unpinned()
|
||||
});
|
||||
|
||||
let subagent_manager =
|
||||
new_shared_subagent_manager(config.workspace.clone(), config.max_subagents);
|
||||
let subagent_manager = new_shared_subagent_manager_with_timeout(
|
||||
config.workspace.clone(),
|
||||
config.max_subagents,
|
||||
config.subagent_heartbeat_timeout,
|
||||
);
|
||||
let shell_manager = config
|
||||
.runtime_services
|
||||
.shell_manager
|
||||
|
||||
@@ -79,7 +79,7 @@ impl Engine {
|
||||
const MAX_STREAM_RETRIES: u32 = 3;
|
||||
let mut stream_retry_attempts: u32 = 0;
|
||||
|
||||
loop {
|
||||
'turn_loop: loop {
|
||||
if self.cancel_token.is_cancelled() {
|
||||
let _ = self.tx_event.send(Event::status("Request cancelled")).await;
|
||||
return (TurnOutcomeStatus::Interrupted, None);
|
||||
@@ -1005,11 +1005,14 @@ impl Engine {
|
||||
completions.push(c);
|
||||
}
|
||||
if completions.is_empty() {
|
||||
let running = {
|
||||
let mgr = self.subagent_manager.read().await;
|
||||
mgr.running_count()
|
||||
};
|
||||
if should_hold_turn_for_subagents(completions.len(), running) {
|
||||
loop {
|
||||
let running = {
|
||||
let mgr = self.subagent_manager.read().await;
|
||||
mgr.running_count()
|
||||
};
|
||||
if !should_hold_turn_for_subagents(completions.len(), running) {
|
||||
break;
|
||||
}
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!(
|
||||
@@ -1032,6 +1035,7 @@ impl Engine {
|
||||
while let Ok(extra) = self.rx_subagent_completion.try_recv() {
|
||||
completions.push(extra);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(steer) = self.rx_steer.recv() => {
|
||||
let trimmed = steer.trim().to_string();
|
||||
@@ -1052,7 +1056,21 @@ impl Engine {
|
||||
.await;
|
||||
}
|
||||
turn.next_step();
|
||||
continue;
|
||||
continue 'turn_loop;
|
||||
}
|
||||
() = tokio::time::sleep(self.config.subagent_heartbeat_timeout) => {
|
||||
let auto_cancelled = {
|
||||
let mut mgr = self.subagent_manager.write().await;
|
||||
mgr.cleanup(std::time::Duration::from_secs(60 * 60))
|
||||
};
|
||||
if auto_cancelled > 0 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!(
|
||||
"Auto-cancelled {auto_cancelled} stale sub-agent(s) after no progress"
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2000,7 +2000,8 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
"FIREWORKS_API_KEY",
|
||||
"codewhale auth set --provider fireworks --api-key \"...\"",
|
||||
),
|
||||
crate::config::ApiProvider::Siliconflow | crate::config::ApiProvider::SiliconflowCn => (
|
||||
crate::config::ApiProvider::Siliconflow
|
||||
| crate::config::ApiProvider::SiliconflowCn => (
|
||||
"SILICONFLOW_API_KEY",
|
||||
"codewhale auth set --provider siliconflow --api-key \"...\"",
|
||||
),
|
||||
@@ -2044,7 +2045,8 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
crate::config::ApiProvider::XiaomiMimo => "xiaomi_mimo",
|
||||
crate::config::ApiProvider::Novita => "novita",
|
||||
crate::config::ApiProvider::Fireworks => "fireworks",
|
||||
crate::config::ApiProvider::Siliconflow | crate::config::ApiProvider::SiliconflowCn => "siliconflow",
|
||||
crate::config::ApiProvider::Siliconflow
|
||||
| crate::config::ApiProvider::SiliconflowCn => "siliconflow",
|
||||
crate::config::ApiProvider::Arcee => "arcee",
|
||||
crate::config::ApiProvider::Moonshot => "moonshot",
|
||||
crate::config::ApiProvider::Sglang => "sglang",
|
||||
@@ -5709,6 +5711,9 @@ async fn run_exec_agent(
|
||||
runtime_services: crate::tools::spec::RuntimeToolServices::default(),
|
||||
subagent_model_overrides: config.subagent_model_overrides(),
|
||||
subagent_api_timeout: std::time::Duration::from_secs(config.subagent_api_timeout_secs()),
|
||||
subagent_heartbeat_timeout: std::time::Duration::from_secs(
|
||||
config.subagent_heartbeat_timeout_secs(),
|
||||
),
|
||||
prefer_bwrap: config.prefer_bwrap.unwrap_or(false),
|
||||
memory_enabled: config.memory_enabled(),
|
||||
memory_path: config.memory_path(),
|
||||
|
||||
+35
-1
@@ -1113,12 +1113,21 @@ fn is_mcp_stale_session_body(body: &str) -> bool {
|
||||
|
||||
fn is_mcp_stale_session_error(err: &anyhow::Error) -> bool {
|
||||
let err = format!("{err:#}");
|
||||
let lower_err = err.to_ascii_lowercase();
|
||||
err.contains("MCP Streamable HTTP session expired")
|
||||
|| err.contains("MCP session expired")
|
||||
|| err.contains("SSE transport closed")
|
||||
|| (err.contains("MCP SSE POST send failed") && is_connection_closed_error_text(&lower_err))
|
||||
|| is_mcp_stale_session_body(&err)
|
||||
}
|
||||
|
||||
fn is_connection_closed_error_text(err: &str) -> bool {
|
||||
err.contains("connection closed")
|
||||
|| err.contains("connection reset")
|
||||
|| err.contains("broken pipe")
|
||||
|| err.contains("unexpected eof")
|
||||
}
|
||||
|
||||
fn parse_sse_message_data(body: &str) -> Vec<Vec<u8>> {
|
||||
let normalized = body.replace("\r\n", "\n");
|
||||
let mut messages = Vec::new();
|
||||
@@ -1205,7 +1214,13 @@ impl McpTransport for SseTransport {
|
||||
)
|
||||
.body(msg)
|
||||
.send()
|
||||
.await?;
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"MCP SSE POST send failed (transport=sse endpoint={})",
|
||||
mask_url_secrets(endpoint)
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body_excerpt = bounded_body_excerpt(response, ERROR_BODY_PREVIEW_BYTES).await;
|
||||
@@ -3659,6 +3674,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_sse_post_disconnect_is_retryable() {
|
||||
let err = anyhow::anyhow!(
|
||||
"MCP SSE POST send failed (transport=sse endpoint=http://127.0.0.1:123/messages): connection closed before message completed"
|
||||
);
|
||||
assert!(
|
||||
is_mcp_stale_session_error(&err),
|
||||
"closed legacy SSE POST should force reconnect before retry"
|
||||
);
|
||||
|
||||
let err = anyhow::anyhow!(
|
||||
"MCP SSE POST send failed (transport=sse endpoint=http://127.0.0.1:123/messages): connection reset by peer"
|
||||
);
|
||||
assert!(
|
||||
is_mcp_stale_session_error(&err),
|
||||
"reset legacy SSE POST should force reconnect before retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_all_ignores_unsupported_optional_capabilities() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
@@ -2020,6 +2020,9 @@ impl RuntimeThreadManager {
|
||||
subagent_api_timeout: std::time::Duration::from_secs(
|
||||
self.config.subagent_api_timeout_secs(),
|
||||
),
|
||||
subagent_heartbeat_timeout: std::time::Duration::from_secs(
|
||||
self.config.subagent_heartbeat_timeout_secs(),
|
||||
),
|
||||
prefer_bwrap: self.config.prefer_bwrap.unwrap_or(false),
|
||||
memory_enabled: self.config.memory_enabled(),
|
||||
memory_path: self.config.memory_path(),
|
||||
|
||||
@@ -1017,6 +1017,7 @@ pub struct SubAgent {
|
||||
pub result: Option<String>,
|
||||
pub steps_taken: u32,
|
||||
pub started_at: Instant,
|
||||
pub last_activity_at: Instant,
|
||||
/// `None` = full registry inheritance, with approval-gated tools still
|
||||
/// blocked unless the parent runtime is auto-approved.
|
||||
/// `Some(list)` = explicit narrow allowlist (Custom agents, legacy).
|
||||
@@ -1046,6 +1047,7 @@ impl SubAgent {
|
||||
) -> Self {
|
||||
let session_name = id.clone();
|
||||
|
||||
let started_at = Instant::now();
|
||||
Self {
|
||||
id,
|
||||
session_name,
|
||||
@@ -1058,7 +1060,8 @@ impl SubAgent {
|
||||
status: SubAgentStatus::Running,
|
||||
result: None,
|
||||
steps_taken: 0,
|
||||
started_at: Instant::now(),
|
||||
started_at,
|
||||
last_activity_at: started_at,
|
||||
allowed_tools,
|
||||
session_boot_id,
|
||||
input_tx: Some(input_tx),
|
||||
@@ -1099,6 +1102,7 @@ pub struct SubAgentManager {
|
||||
state_path: Option<PathBuf>,
|
||||
max_steps: u32,
|
||||
max_agents: usize,
|
||||
running_heartbeat_timeout: Duration,
|
||||
/// Stable id assigned at manager construction (#405). Stamped on
|
||||
/// every agent the manager spawns; agents loaded from the
|
||||
/// persisted state file carry whatever id the prior session
|
||||
@@ -1118,6 +1122,9 @@ impl SubAgentManager {
|
||||
state_path: None,
|
||||
max_steps: DEFAULT_MAX_STEPS,
|
||||
max_agents,
|
||||
running_heartbeat_timeout: Duration::from_secs(
|
||||
crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
|
||||
),
|
||||
// Fresh boot id per manager. Used by #405 to classify
|
||||
// re-loaded persisted agents as "prior session".
|
||||
current_session_boot_id: format!("boot_{}", &Uuid::new_v4().to_string()[..12]),
|
||||
@@ -1145,6 +1152,16 @@ impl SubAgentManager {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_running_heartbeat_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.running_heartbeat_timeout = if timeout.is_zero() {
|
||||
Duration::from_secs(crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS)
|
||||
} else {
|
||||
timeout
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
fn persist_state(&self) -> Result<()> {
|
||||
let Some(path) = self.state_path.as_ref() else {
|
||||
return Ok(());
|
||||
@@ -1244,6 +1261,7 @@ impl SubAgentManager {
|
||||
result: persisted.result,
|
||||
steps_taken: persisted.steps_taken,
|
||||
started_at,
|
||||
last_activity_at: started_at,
|
||||
allowed_tools,
|
||||
// Empty string when loading pre-#405 records; the
|
||||
// manager treats that the same as a non-matching id —
|
||||
@@ -1274,11 +1292,28 @@ impl SubAgentManager {
|
||||
// Keep recently finished handles counted until the terminal
|
||||
// status update has reconciled. Otherwise a fanout burst can
|
||||
// refill the cap before the UI/state catches up (#2211).
|
||||
true
|
||||
!self.running_heartbeat_timed_out(agent)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
fn running_heartbeat_timed_out(&self, agent: &SubAgent) -> bool {
|
||||
agent.status == SubAgentStatus::Running
|
||||
&& agent.task_handle.is_some()
|
||||
&& agent.last_activity_at.elapsed() >= self.running_heartbeat_timeout
|
||||
}
|
||||
|
||||
pub fn touch(&mut self, agent_id: &str) -> bool {
|
||||
let Some(agent) = self.agents.get_mut(agent_id) else {
|
||||
return false;
|
||||
};
|
||||
if agent.status != SubAgentStatus::Running {
|
||||
return false;
|
||||
}
|
||||
agent.last_activity_at = Instant::now();
|
||||
true
|
||||
}
|
||||
|
||||
/// Spawn a new background sub-agent.
|
||||
pub fn spawn_background(
|
||||
&mut self,
|
||||
@@ -1548,6 +1583,7 @@ impl SubAgentManager {
|
||||
agent.result = None;
|
||||
agent.steps_taken = 0;
|
||||
agent.started_at = restarted_at;
|
||||
agent.last_activity_at = restarted_at;
|
||||
agent.input_tx = Some(input_tx);
|
||||
agent.task_handle = Some(handle);
|
||||
|
||||
@@ -1740,9 +1776,37 @@ impl SubAgentManager {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Clean up completed agents older than the given duration.
|
||||
pub fn cleanup(&mut self, max_age: Duration) {
|
||||
/// Clean up stale running agents and completed agents older than the
|
||||
/// given duration. Returns the number of running agents auto-cancelled
|
||||
/// during this pass.
|
||||
pub fn cleanup(&mut self, max_age: Duration) -> usize {
|
||||
let before = self.agents.len();
|
||||
let mut auto_cancelled = 0;
|
||||
let timeout = self.running_heartbeat_timeout;
|
||||
for agent in self.agents.values_mut() {
|
||||
if agent.status == SubAgentStatus::Running
|
||||
&& agent.task_handle.is_some()
|
||||
&& agent.last_activity_at.elapsed() >= timeout
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "subagent",
|
||||
agent_id = %agent.id,
|
||||
timeout_secs = timeout.as_secs(),
|
||||
"auto-cancelling stale sub-agent with no manager-visible progress"
|
||||
);
|
||||
agent.status = SubAgentStatus::Cancelled;
|
||||
agent.result = Some(format!(
|
||||
"Auto-cancelled after {}s without sub-agent progress.",
|
||||
timeout.as_secs()
|
||||
));
|
||||
release_resident_leases_for(&agent.id);
|
||||
if let Some(handle) = agent.task_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
agent.input_tx = None;
|
||||
auto_cancelled += 1;
|
||||
}
|
||||
}
|
||||
self.agents.retain(|_, agent| {
|
||||
if agent.status == SubAgentStatus::Running {
|
||||
true
|
||||
@@ -1750,9 +1814,10 @@ impl SubAgentManager {
|
||||
agent.started_at.elapsed() < max_age
|
||||
}
|
||||
});
|
||||
if self.agents.len() != before {
|
||||
if self.agents.len() != before || auto_cancelled > 0 {
|
||||
self.persist_state_best_effort();
|
||||
}
|
||||
auto_cancelled
|
||||
}
|
||||
|
||||
fn update_from_result(&mut self, agent_id: &str, result: SubAgentResult) {
|
||||
@@ -1918,9 +1983,26 @@ fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
/// Create a shared sub-agent manager with a configurable limit.
|
||||
#[must_use]
|
||||
pub fn new_shared_subagent_manager(workspace: PathBuf, max_agents: usize) -> SharedSubAgentManager {
|
||||
new_shared_subagent_manager_with_timeout(
|
||||
workspace,
|
||||
max_agents,
|
||||
Duration::from_secs(crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a shared sub-agent manager with configurable concurrency and stale
|
||||
/// running-agent heartbeat timeout.
|
||||
#[must_use]
|
||||
pub fn new_shared_subagent_manager_with_timeout(
|
||||
workspace: PathBuf,
|
||||
max_agents: usize,
|
||||
running_heartbeat_timeout: Duration,
|
||||
) -> SharedSubAgentManager {
|
||||
let max_agents = max_agents.clamp(1, MAX_SUBAGENTS);
|
||||
let state_path = default_state_path(&workspace);
|
||||
let mut manager = SubAgentManager::new(workspace, max_agents).with_state_path(state_path);
|
||||
let mut manager = SubAgentManager::new(workspace, max_agents)
|
||||
.with_running_heartbeat_timeout(running_heartbeat_timeout)
|
||||
.with_state_path(state_path);
|
||||
if let Err(err) = manager.load_state() {
|
||||
// Routed through tracing instead of stderr — see comment in
|
||||
// `persist_state_best_effort` above.
|
||||
@@ -3735,6 +3817,18 @@ async fn insert_subagent_full_transcript_handle(
|
||||
store.insert_json(format!("agent:{agent_id}"), "full_transcript", payload)
|
||||
}
|
||||
|
||||
fn record_agent_progress(runtime: &SubAgentRuntime, agent_id: &str, message: impl Into<String>) {
|
||||
if let Ok(mut manager) = runtime.manager.try_write() {
|
||||
manager.touch(agent_id);
|
||||
}
|
||||
emit_agent_progress(
|
||||
runtime.event_tx.as_ref(),
|
||||
runtime.mailbox.as_ref(),
|
||||
agent_id,
|
||||
message.into(),
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
async fn run_subagent(
|
||||
runtime: &SubAgentRuntime,
|
||||
@@ -3779,9 +3873,8 @@ async fn run_subagent(
|
||||
if let Some(mb) = runtime.mailbox.as_ref() {
|
||||
let _ = mb.send(MailboxMessage::started(&agent_id, agent_type.clone()));
|
||||
}
|
||||
emit_agent_progress(
|
||||
runtime.event_tx.as_ref(),
|
||||
runtime.mailbox.as_ref(),
|
||||
record_agent_progress(
|
||||
runtime,
|
||||
&agent_id,
|
||||
format!("started ({})", agent_type.as_str()),
|
||||
);
|
||||
@@ -3796,9 +3889,8 @@ async fn run_subagent(
|
||||
// while we were between steps. Top-level model-visible sub-agents use
|
||||
// a detached token so parent turn cancellation does not stop them.
|
||||
if runtime.cancel_token.is_cancelled() {
|
||||
emit_agent_progress(
|
||||
runtime.event_tx.as_ref(),
|
||||
runtime.mailbox.as_ref(),
|
||||
record_agent_progress(
|
||||
runtime,
|
||||
&agent_id,
|
||||
format!("step {steps}/{max_steps}: cancelled"),
|
||||
);
|
||||
@@ -3845,9 +3937,8 @@ async fn run_subagent(
|
||||
}
|
||||
|
||||
steps += 1;
|
||||
emit_agent_progress(
|
||||
runtime.event_tx.as_ref(),
|
||||
runtime.mailbox.as_ref(),
|
||||
record_agent_progress(
|
||||
runtime,
|
||||
&agent_id,
|
||||
format!("step {steps}/{max_steps}: requesting model response"),
|
||||
);
|
||||
@@ -3892,9 +3983,8 @@ async fn run_subagent(
|
||||
let response = tokio::select! {
|
||||
biased;
|
||||
() = runtime.cancel_token.cancelled() => {
|
||||
emit_agent_progress(
|
||||
runtime.event_tx.as_ref(),
|
||||
runtime.mailbox.as_ref(),
|
||||
record_agent_progress(
|
||||
runtime,
|
||||
&agent_id,
|
||||
format!("step {steps}/{max_steps}: cancelled mid-request"),
|
||||
);
|
||||
@@ -3980,9 +4070,8 @@ async fn run_subagent(
|
||||
tool_uses.len()
|
||||
)
|
||||
};
|
||||
emit_agent_progress(
|
||||
runtime.event_tx.as_ref(),
|
||||
runtime.mailbox.as_ref(),
|
||||
record_agent_progress(
|
||||
runtime,
|
||||
&agent_id,
|
||||
format!("step {steps}/{max_steps}: {progress}"),
|
||||
);
|
||||
@@ -4006,9 +4095,8 @@ async fn run_subagent(
|
||||
pending_inputs.push_back(input);
|
||||
}
|
||||
if pending_inputs.is_empty() {
|
||||
emit_agent_progress(
|
||||
runtime.event_tx.as_ref(),
|
||||
runtime.mailbox.as_ref(),
|
||||
record_agent_progress(
|
||||
runtime,
|
||||
&agent_id,
|
||||
format!("step {steps}/{max_steps}: complete"),
|
||||
);
|
||||
@@ -4017,9 +4105,8 @@ async fn run_subagent(
|
||||
continue;
|
||||
}
|
||||
|
||||
emit_agent_progress(
|
||||
runtime.event_tx.as_ref(),
|
||||
runtime.mailbox.as_ref(),
|
||||
record_agent_progress(
|
||||
runtime,
|
||||
&agent_id,
|
||||
format!(
|
||||
"step {steps}/{max_steps}: executing {} tool call(s)",
|
||||
@@ -4028,9 +4115,8 @@ async fn run_subagent(
|
||||
);
|
||||
let mut tool_results: Vec<ContentBlock> = Vec::new();
|
||||
for (tool_id, tool_name, tool_input) in tool_uses {
|
||||
emit_agent_progress(
|
||||
runtime.event_tx.as_ref(),
|
||||
runtime.mailbox.as_ref(),
|
||||
record_agent_progress(
|
||||
runtime,
|
||||
&agent_id,
|
||||
format!("step {steps}/{max_steps}: running tool '{tool_name}'"),
|
||||
);
|
||||
@@ -4053,9 +4139,8 @@ async fn run_subagent(
|
||||
Err(_) => format!("Error: Tool {tool_name} timed out"),
|
||||
};
|
||||
let tool_ok = !result.starts_with("Error:");
|
||||
emit_agent_progress(
|
||||
runtime.event_tx.as_ref(),
|
||||
runtime.mailbox.as_ref(),
|
||||
record_agent_progress(
|
||||
runtime,
|
||||
&agent_id,
|
||||
format!("step {steps}/{max_steps}: finished tool '{tool_name}'"),
|
||||
);
|
||||
|
||||
@@ -962,6 +962,122 @@ async fn test_running_count_counts_running_agents_until_status_reconciles() {
|
||||
assert_eq!(manager.running_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1)
|
||||
.with_running_heartbeat_timeout(Duration::from_secs(300));
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
"test_agent_stale".to_string(),
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
"deepseek-v4-flash".to_string(),
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
agent.last_activity_at = instant_from_duration(Duration::from_secs(600));
|
||||
agent.task_handle = Some(tokio::spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}));
|
||||
let agent_id = agent.id.clone();
|
||||
manager.agents.insert(agent_id.clone(), agent);
|
||||
|
||||
assert_eq!(
|
||||
manager.running_count(),
|
||||
0,
|
||||
"stale running agents must not keep the concurrency slot occupied"
|
||||
);
|
||||
assert_eq!(manager.cleanup(Duration::from_secs(60 * 60)), 1);
|
||||
|
||||
let snapshot = manager
|
||||
.get_result(&agent_id)
|
||||
.expect("agent should remain inspectable");
|
||||
assert_eq!(snapshot.status, SubAgentStatus::Cancelled);
|
||||
assert_eq!(manager.running_count(), 0);
|
||||
assert!(
|
||||
snapshot
|
||||
.result
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Auto-cancelled")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_keeps_recent_running_agent() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1)
|
||||
.with_running_heartbeat_timeout(Duration::from_secs(300));
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
"test_agent_recent".to_string(),
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
"deepseek-v4-flash".to_string(),
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
agent.last_activity_at = Instant::now();
|
||||
agent.task_handle = Some(tokio::spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}));
|
||||
let agent_id = agent.id.clone();
|
||||
manager.agents.insert(agent_id.clone(), agent);
|
||||
|
||||
assert_eq!(manager.running_count(), 1);
|
||||
assert_eq!(manager.cleanup(Duration::from_secs(60 * 60)), 0);
|
||||
assert_eq!(
|
||||
manager.get_result(&agent_id).expect("agent").status,
|
||||
SubAgentStatus::Running
|
||||
);
|
||||
manager
|
||||
.agents
|
||||
.get_mut(&agent_id)
|
||||
.and_then(|agent| agent.task_handle.take())
|
||||
.expect("live task handle")
|
||||
.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn touch_refreshes_stale_running_agent_heartbeat() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1)
|
||||
.with_running_heartbeat_timeout(Duration::from_secs(300));
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
"test_agent_touched".to_string(),
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
"deepseek-v4-flash".to_string(),
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
agent.last_activity_at = instant_from_duration(Duration::from_secs(600));
|
||||
agent.task_handle = Some(tokio::spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}));
|
||||
let agent_id = agent.id.clone();
|
||||
manager.agents.insert(agent_id.clone(), agent);
|
||||
|
||||
assert_eq!(manager.running_count(), 0);
|
||||
assert!(manager.touch(&agent_id));
|
||||
assert_eq!(manager.running_count(), 1);
|
||||
assert_eq!(manager.cleanup(Duration::from_secs(60 * 60)), 0);
|
||||
manager
|
||||
.agents
|
||||
.get_mut(&agent_id)
|
||||
.and_then(|agent| agent.task_handle.take())
|
||||
.expect("live task handle")
|
||||
.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assign_updates_running_agent_and_sends_message() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 2);
|
||||
|
||||
@@ -699,7 +699,7 @@ pub fn latest_assistant_text(messages: &[Message]) -> Option<String> {
|
||||
| ContentBlock::ServerToolUse { .. }
|
||||
| ContentBlock::ToolSearchToolResult { .. }
|
||||
| ContentBlock::CodeExecutionToolResult { .. } => None,
|
||||
| ContentBlock::ImageUrl { .. } => None,
|
||||
ContentBlock::ImageUrl { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
@@ -790,9 +790,7 @@ fn message_text_for_history(message: &crate::models::Message) -> String {
|
||||
| crate::models::ContentBlock::CodeExecutionToolResult { content, .. } => {
|
||||
format!("tool result: {}", truncate(&content.to_string(), 220))
|
||||
}
|
||||
crate::models::ContentBlock::ImageUrl { .. } => {
|
||||
String::from("[image]")
|
||||
}
|
||||
crate::models::ContentBlock::ImageUrl { .. } => String::from("[image]"),
|
||||
};
|
||||
let part = part.trim();
|
||||
if !part.is_empty() {
|
||||
|
||||
@@ -871,6 +871,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
runtime_services: app.runtime_services.clone(),
|
||||
subagent_model_overrides: config.subagent_model_overrides(),
|
||||
subagent_api_timeout: Duration::from_secs(config.subagent_api_timeout_secs()),
|
||||
subagent_heartbeat_timeout: Duration::from_secs(config.subagent_heartbeat_timeout_secs()),
|
||||
prefer_bwrap: config.prefer_bwrap.unwrap_or(false),
|
||||
memory_enabled: config.memory_enabled(),
|
||||
memory_path: config.memory_path(),
|
||||
@@ -6604,7 +6605,9 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
crate::config::ApiProvider::XiaomiMimo => Some("MiMo"),
|
||||
crate::config::ApiProvider::Novita => Some("Novita"),
|
||||
crate::config::ApiProvider::Fireworks => Some("Fireworks"),
|
||||
crate::config::ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Some("SiliconFlow"),
|
||||
crate::config::ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => {
|
||||
Some("SiliconFlow")
|
||||
}
|
||||
crate::config::ApiProvider::Arcee => Some("Arcee"),
|
||||
crate::config::ApiProvider::Moonshot => Some("Kimi"),
|
||||
crate::config::ApiProvider::Sglang => Some("SGLang"),
|
||||
|
||||
@@ -708,11 +708,14 @@ If you are upgrading from older releases:
|
||||
related persistent sub-agent sessions. Explicit tool `model` values win, then role/type
|
||||
overrides, then the parent runtime model. Supported convenience keys are
|
||||
`default_model`, `worker_model`, `explorer_model`, `awaiter_model`,
|
||||
`review_model`, `custom_model`, `max_concurrent`, and `api_timeout_secs`. The
|
||||
`[subagents] max_concurrent` value overrides top-level `max_subagents` and is
|
||||
also clamped to `1..=20`; `[subagents] api_timeout_secs` controls the
|
||||
per-step API timeout for sub-agent model calls and is clamped to `1..=1800`,
|
||||
with `0` or unset preserving the legacy 120 second default.
|
||||
`review_model`, `custom_model`, `max_concurrent`, `api_timeout_secs`, and
|
||||
`heartbeat_timeout_secs`. The `[subagents] max_concurrent` value overrides
|
||||
top-level `max_subagents` and is also clamped to `1..=20`; `[subagents]
|
||||
api_timeout_secs` controls the per-step API timeout for sub-agent model calls
|
||||
and is clamped to `1..=1800`, with `0` or unset preserving the legacy 120
|
||||
second default. `[subagents] heartbeat_timeout_secs` controls stale running
|
||||
agent cleanup, defaults to `300`, and is clamped to `30..=3600` while staying
|
||||
above the resolved API timeout.
|
||||
`[subagents.models]` accepts lower-case role or type keys such as `worker`,
|
||||
`explorer`, `general`, `explore`, `plan`, and `review`. Values must normalize
|
||||
to a supported DeepSeek model id before an agent is spawned.
|
||||
|
||||
@@ -127,6 +127,22 @@ api_timeout_secs = 900 # 15 minutes; clamped to 1..=1800
|
||||
Values are clamped to `1..=1800`. `0` and `unset` keep the legacy
|
||||
`120` second default, so existing installs see no behavior change.
|
||||
|
||||
## Stale-agent heartbeat (#2614)
|
||||
|
||||
Running agents also track manager-visible progress. If a child stops emitting
|
||||
progress for the heartbeat window, the manager auto-cancels it, releases its
|
||||
sub-agent slot, and keeps the cancelled record inspectable via `agent_eval` /
|
||||
`agent_list`. The default is 5 minutes:
|
||||
|
||||
```toml
|
||||
[subagents]
|
||||
heartbeat_timeout_secs = 300 # clamped to 30..=3600
|
||||
```
|
||||
|
||||
The effective heartbeat is kept at least 30 seconds above
|
||||
`api_timeout_secs`, so a configured long model request is not cancelled before
|
||||
its own request timeout can fire.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Each opened session produces a record that progresses through:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codewhale",
|
||||
"version": "0.8.51",
|
||||
"codewhaleBinaryVersion": "0.8.51",
|
||||
"version": "0.8.52",
|
||||
"codewhaleBinaryVersion": "0.8.52",
|
||||
"description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.",
|
||||
"author": "Hmbown",
|
||||
"license": "MIT",
|
||||
|
||||
Reference in New Issue
Block a user