mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
fix(engine): Anthropic tool-name encoding, usage-journal headroom, pre-turn watchdog, lenient session list
Reproduced from a founder session on a 0.10.3 dev build (artifacts
subagent-failures-20261107): sub-agents died with "Child usage settlement
failed", a turn failed on "native usage journal capacity reached before
dispatch", and two workers returned Anthropic 400 "tools.17.custom.name".
- Anthropic client: tool names were sent raw, so a name such as web.run (from
tool_search) invalidated the whole request. Outbound tool declarations,
replayed tool_use blocks, tool_choice and the omitted-tool list now use
to_api_tool_name, and streamed and non-streamed tool_use names are decoded
with from_api_tool_name, matching the chat and responses clients.
- Usage journal: MAX_RUNTIME_USAGE_RECORDS_PER_OWNER 64 -> 256. Children share
the session's journal owner and the workflow default is 64 live agents with a
ceiling of 128, so a full fan-out plus the parent exceeded 64. be43ea93fd
already makes the overflow non-fatal; this keeps the receipts.
- Turn start (CW-6633, #6184): the heartbeat stayed Idle until TurnStarted, so
a wait before it produced no log, no stall record and no cancel. The
Preparing bound (180 s) now starts at the top of handle_admitted_operation
with an IdleOnDrop guard, and owner_work warns when all four slots are held.
The root cause of #6184 is still unproven.
- Session list (CW-7553, #7095): list_sessions failed whole on one unreadable
record and two callers turned that Err into an empty list. New
SessionManager::list_sessions_lenient returns the readable records, newest
first, and logs the rest; the launch card and ACP session/list use it. The
strict list_sessions stays for retention and prune.
- NSIS uninstall (CW-7542, #7091): also removes .codewhale-install.sha256 and
the codewhale.old-*/codew.old-* files a self-update leaves. makensis compiled
the script with placeholder inputs.
Verified on macOS with an isolated HOME: codewhale-engine lib client::,
cost_status, turn_heartbeat, session_manager: 829 passed/0 failed; cargo check
of codewhale-tui, codewhale-app-server and codewhale-runtime clean. Not run: hosted
CI, Windows, a live Anthropic request, or a real >64-child fan-out.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
94d78d1ab4
commit
0d1ffc1cb2
10 files changed
+99
-24
No files matched your search
@@ -368,6 +368,12 @@ until the matching tag, packages, checksums, and release assets exist.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it.
|
||||
- **A large sub-agent fan-out no longer exhausts the usage journal.** The per-session limit of live usage receipts rises from 64 to 256, above the workflow ceiling of 128, so a full fan-out keeps its cost receipts instead of running with cost unknown.
|
||||
- **A turn that stalls before it starts is now bounded.** The stall watchdog covers the setup before the first model request, so a wedged start is logged, cancelled after 180 seconds with a stated reason, and leaves a stall record instead of waiting silently. A warning names the case where all four blocking-work slots are held. Refs #6184.
|
||||
- **One unreadable saved session no longer empties the recent-sessions list.** The launch card and ACP `session/list` show the readable sessions and log the failure. Refs #7095.
|
||||
- **Windows uninstall removes the receipt and update leftovers.** The installer's uninstaller also deletes `.codewhale-install.sha256` and `codewhale.old-*`/`codew.old-*` left by a self-update, so `bin` can be removed. Refs #7091.
|
||||
- **Windows `/undo` matches recorded file paths regardless of case.** A write recorded under a different drive-letter or directory case, or a `\\?\` prefix, is no longer dropped from the restore plan. Refs #6970.
|
||||
- **Windows: Fleet and metrics read live files again.** Replaying a Fleet ledger and reading audit or receipt records no longer fail with a sharing violation while their writer is still open; the same regular-file, link and identity checks apply. Plugin enablement now says which snapshot check refused (stage root, content identity or final check) instead of one generic message. Generated protocol and SDK exports keep LF line endings in Windows checkouts.
|
||||
- **A held coordination lock no longer makes a new session read-only.** When another session owns the delegated-agent store, the new session starts on private state and says so, instead of refusing every sub-agent with “Failed to clean up sub-agents”.
|
||||
- **A dev server can outlive the turn in an interactive session.** `persist:true` now works in the TUI and desktop when the thread has Full Access; restricted postures and Fleet/sub-agents still refuse it.
|
||||
|
||||
@@ -481,7 +481,7 @@ impl AcpServer {
|
||||
let sessions =
|
||||
codewhale_engine::session_manager::SessionManager::new(self.sessions_dir.clone())
|
||||
.ok()
|
||||
.and_then(|manager| manager.list_sessions().ok())
|
||||
.map(|manager| manager.list_sessions_lenient())
|
||||
.unwrap_or_default();
|
||||
let sessions: Vec<Value> = sessions
|
||||
.into_iter()
|
||||
|
||||
@@ -34,14 +34,19 @@ use codewhale_models::tool_projection::{
|
||||
RouteToolProjection, ToolSchemaProjection, description_with_constraint_note, forced_tool_name,
|
||||
project_tool_schema,
|
||||
};
|
||||
use codewhale_models::{ContentBlock, MessageRequest, MessageResponse, StreamEvent, Usage};
|
||||
use codewhale_models::{
|
||||
ContentBlock, ContentBlockStart, MessageRequest, MessageResponse, StreamEvent, Usage,
|
||||
};
|
||||
use codewhale_runtime::config::wire_model_for_provider_route;
|
||||
use codewhale_runtime::logging;
|
||||
|
||||
use super::CodewhaleClient;
|
||||
use super::prepared::WireDialect;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{extract_sse_data_value, next_sse_line, push_sse_event_data};
|
||||
use super::wire::{
|
||||
extract_sse_data_value, from_api_tool_name, next_sse_line, push_sse_event_data,
|
||||
to_api_tool_name,
|
||||
};
|
||||
|
||||
/// Maximum `cache_control` breakpoints Anthropic accepts per request.
|
||||
const MAX_CACHE_BREAKPOINTS: usize = 4;
|
||||
@@ -120,7 +125,7 @@ impl CodewhaleClient {
|
||||
constraint_note,
|
||||
} => {
|
||||
let mut value = json!({
|
||||
"name": tool.name,
|
||||
"name": to_api_tool_name(&tool.name),
|
||||
"description": description_with_constraint_note(
|
||||
&tool.description,
|
||||
constraint_note,
|
||||
@@ -137,7 +142,7 @@ impl CodewhaleClient {
|
||||
}
|
||||
ToolSchemaProjection::Omitted { .. } => {
|
||||
omitted_logical_names.push(tool.name.clone());
|
||||
omitted_tool_names.push(tool.name.clone());
|
||||
omitted_tool_names.push(to_api_tool_name(&tool.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,12 +525,26 @@ fn discard_provider_execution_ids(value: &mut Value) {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_tool_use_names(value: &mut Value) {
|
||||
let Some(content) = value.get_mut("content").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
for block in content.iter_mut() {
|
||||
if block.get("type").and_then(Value::as_str) == Some("tool_use")
|
||||
&& let Some(name) = block.get("name").and_then(Value::as_str)
|
||||
{
|
||||
block["name"] = json!(from_api_tool_name(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_anthropic_message(mut value: Value) -> Result<MessageResponse> {
|
||||
discard_provider_execution_ids(&mut value);
|
||||
if let Some(usage) = value.get_mut("usage") {
|
||||
*usage = json!(parse_anthropic_usage(usage));
|
||||
}
|
||||
normalize_redacted_thinking_blocks(&mut value);
|
||||
decode_tool_use_names(&mut value);
|
||||
serde_json::from_value(value).context("Failed to decode Anthropic Messages response")
|
||||
}
|
||||
|
||||
@@ -777,7 +796,7 @@ fn anthropic_tool_choice(tool_choice: &Value, model: &str) -> Value {
|
||||
Some("auto") => json!({ "type": "auto" }),
|
||||
Some("none") => json!({ "type": "none" }),
|
||||
Some("any" | "required") => json!({ "type": "any" }),
|
||||
Some(name) => json!({ "type": "tool", "name": name }),
|
||||
Some(name) => json!({ "type": "tool", "name": to_api_tool_name(name) }),
|
||||
None => tool_choice.clone(),
|
||||
}
|
||||
}
|
||||
@@ -925,7 +944,7 @@ fn content_block_to_anthropic(block: &ContentBlock) -> Option<Value> {
|
||||
} => Some(json!({
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"name": to_api_tool_name(name),
|
||||
"input": input,
|
||||
})),
|
||||
ContentBlock::ToolResult {
|
||||
@@ -1084,7 +1103,16 @@ fn convert_anthropic_sse_data(data: &str) -> Option<Result<StreamEvent>> {
|
||||
// Local-only receipt: the legacy path ignored it (not a provider
|
||||
// type), so it stays ignored rather than decoding.
|
||||
Ok(StreamEvent::ToolProjectionWarning { .. }) => None,
|
||||
Ok(event) => Some(Ok(event)),
|
||||
Ok(mut event) => {
|
||||
if let StreamEvent::ContentBlockStart {
|
||||
content_block: ContentBlockStart::ToolUse { name, .. },
|
||||
..
|
||||
} = &mut event
|
||||
{
|
||||
*name = from_api_tool_name(name);
|
||||
}
|
||||
Some(Ok(event))
|
||||
}
|
||||
Err(error) => {
|
||||
// Cold path, reached only when direct decode fails: invalid JSON
|
||||
// and unknown types keep their exact legacy outcomes.
|
||||
|
||||
@@ -6768,6 +6768,12 @@ impl Engine {
|
||||
review: Option<codewhale_protocol::op::ReviewOperation>,
|
||||
) -> SendMessageOutcome {
|
||||
let setup_timing = codewhale_runtime::logging::PhaseTimer::start("turn_setup");
|
||||
self.turn_heartbeat.enter(
|
||||
turn_heartbeat::TurnPhase::Preparing,
|
||||
Some("starting the turn".to_string()),
|
||||
Some(turn_heartbeat::PREPARING_PHASE_BOUND),
|
||||
);
|
||||
let _pre_turn_idle = turn_heartbeat::IdleOnDrop(Arc::clone(&self.turn_heartbeat));
|
||||
let is_review = review.is_some();
|
||||
let TurnSpec {
|
||||
profile_constitution,
|
||||
|
||||
@@ -1272,7 +1272,7 @@ fn with_pending_state_mut<R>(f: impl FnOnce(&mut ScopedPendingBackgroundCost) ->
|
||||
/// This journal is deliberately separate from the TUI pending-money pool:
|
||||
/// taking one runtime owner's records cannot steal or reset the foreground
|
||||
/// session's `/cost` state.
|
||||
const MAX_RUNTIME_USAGE_RECORDS_PER_OWNER: usize = 64;
|
||||
const MAX_RUNTIME_USAGE_RECORDS_PER_OWNER: usize = 256;
|
||||
|
||||
#[derive(Default)]
|
||||
struct OwnerRuntimeUsageJournal {
|
||||
|
||||
@@ -5888,6 +5888,29 @@ impl SessionManager {
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn list_sessions_lenient(&self) -> Vec<SessionMetadata> {
|
||||
let catalog = match self.scan_session_records() {
|
||||
Ok(catalog) => catalog,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "session listing failed; showing no sessions");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
if let Some(warning) = catalog.warning() {
|
||||
tracing::warn!(%warning, "session listing is incomplete; showing the readable ones");
|
||||
}
|
||||
let mut sessions: Vec<_> = catalog
|
||||
.records
|
||||
.into_iter()
|
||||
.map(|(_, mut metadata)| {
|
||||
self.apply_late_usage_to_metadata(&mut metadata);
|
||||
metadata
|
||||
})
|
||||
.collect();
|
||||
sessions.sort_by_key(|session| std::cmp::Reverse(session.updated_at));
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn list_sessions_with_recovery(
|
||||
&self,
|
||||
) -> std::io::Result<(
|
||||
|
||||
@@ -20,20 +20,23 @@ where
|
||||
static SLOTS: std::sync::OnceLock<std::sync::Arc<tokio::sync::Semaphore>> =
|
||||
std::sync::OnceLock::new();
|
||||
const PERMIT_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
let permit = tokio::time::timeout(
|
||||
PERMIT_WAIT,
|
||||
SLOTS
|
||||
.get_or_init(|| std::sync::Arc::new(tokio::sync::Semaphore::new(4)))
|
||||
.clone()
|
||||
.acquire_owned(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"blocking work is queued behind earlier jobs that have not finished within {}s",
|
||||
PERMIT_WAIT.as_secs()
|
||||
)
|
||||
})??;
|
||||
let slots = SLOTS
|
||||
.get_or_init(|| std::sync::Arc::new(tokio::sync::Semaphore::new(4)))
|
||||
.clone();
|
||||
if slots.available_permits() == 0 {
|
||||
tracing::warn!(
|
||||
target: "turn",
|
||||
"owner_work is waiting: all 4 blocking slots are held by earlier jobs"
|
||||
);
|
||||
}
|
||||
let permit = tokio::time::timeout(PERMIT_WAIT, slots.acquire_owned())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"blocking work is queued behind earlier jobs that have not finished within {}s",
|
||||
PERMIT_WAIT.as_secs()
|
||||
)
|
||||
})??;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let _membership = crate::test_support::join_env_scope(env_ticket);
|
||||
|
||||
@@ -368,6 +368,12 @@ until the matching tag, packages, checksums, and release assets exist.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it.
|
||||
- **A large sub-agent fan-out no longer exhausts the usage journal.** The per-session limit of live usage receipts rises from 64 to 256, above the workflow ceiling of 128, so a full fan-out keeps its cost receipts instead of running with cost unknown.
|
||||
- **A turn that stalls before it starts is now bounded.** The stall watchdog covers the setup before the first model request, so a wedged start is logged, cancelled after 180 seconds with a stated reason, and leaves a stall record instead of waiting silently. A warning names the case where all four blocking-work slots are held. Refs #6184.
|
||||
- **One unreadable saved session no longer empties the recent-sessions list.** The launch card and ACP `session/list` show the readable sessions and log the failure. Refs #7095.
|
||||
- **Windows uninstall removes the receipt and update leftovers.** The installer's uninstaller also deletes `.codewhale-install.sha256` and `codewhale.old-*`/`codew.old-*` left by a self-update, so `bin` can be removed. Refs #7091.
|
||||
- **Windows `/undo` matches recorded file paths regardless of case.** A write recorded under a different drive-letter or directory case, or a `\\?\` prefix, is no longer dropped from the restore plan. Refs #6970.
|
||||
- **Windows: Fleet and metrics read live files again.** Replaying a Fleet ledger and reading audit or receipt records no longer fail with a sharing violation while their writer is still open; the same regular-file, link and identity checks apply. Plugin enablement now says which snapshot check refused (stage root, content identity or final check) instead of one generic message. Generated protocol and SDK exports keep LF line endings in Windows checkouts.
|
||||
- **A held coordination lock no longer makes a new session read-only.** When another session owns the delegated-agent store, the new session starts on private state and says so, instead of refusing every sub-agent with “Failed to clean up sub-agents”.
|
||||
- **A dev server can outlive the turn in an interactive session.** `persist:true` now works in the TUI and desktop when the thread has Full Access; restricted postures and Fleet/sub-agents still refuse it.
|
||||
|
||||
@@ -745,7 +745,7 @@ fn load_launch_recent(workspace: &std::path::Path) -> (Vec<LaunchRecentSession>,
|
||||
let manager = codewhale_engine::session_manager::SessionManager::default_location().ok();
|
||||
let sessions = manager
|
||||
.as_ref()
|
||||
.and_then(|manager| manager.list_sessions().ok())
|
||||
.map(|manager| manager.list_sessions_lenient())
|
||||
.unwrap_or_default();
|
||||
let any_scoped = sessions.iter().any(|session| {
|
||||
!session.archived
|
||||
|
||||
@@ -186,6 +186,9 @@ Section "Uninstall"
|
||||
!insertmacro RemoveOwnedFile "$INSTDIR\bin\codewhale.exe"
|
||||
!insertmacro RemoveOwnedFile "$INSTDIR\bin\codew.exe"
|
||||
!insertmacro RemoveOwnedFile "$INSTDIR\bin\codewhale.bat"
|
||||
Delete "$INSTDIR\bin\.codewhale-install.sha256"
|
||||
Delete "$INSTDIR\bin\codewhale.old-*"
|
||||
Delete "$INSTDIR\bin\codew.old-*"
|
||||
ReadRegDWORD $R0 HKCU "Software\${PRODUCT_NAME}" "CompiledHostInstalled"
|
||||
${If} $R0 == 1
|
||||
!insertmacro RemoveOwnedFile "$INSTDIR\bin\codewhale-extension-host.exe"
|
||||
|
||||
Reference in new issue
Block a user