mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
fix(engine): bookkeeping and missing-origin refusals no longer stop work
A child whose usage settlement is not acknowledged continues, with cost unknown, instead of dying on "Child usage settlement failed". Workspace memory no longer requires a git origin. plugin list is an alias of plugins status. A heavy command runs after 30 seconds instead of waiting forever for an admission slot. An MCP registration 4xx includes the status and the manual client-id path. Verification: cargo check -p codewhale-engine --lib passed. codewhale-tui memory_adapter_workspace_identity_resolves_and_preserves_errors and memory_adapter_deletes_workspace_without_an_origin passed 2/2. codewhale-engine lib tests compiled the admission and OAuth edits (heavy_command_admission filter matched 0 tests).
This commit is contained in:
1 parent
5de1da95f4
commit
3c7a43252c
8 files changed
+107
-37
No files matched your search
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A child no longer dies when usage settlement is not acknowledged.** The turn continues and cost stays unknown, matching the parent path.
|
||||
- **Workspace memory works without a git origin.** A plain folder or a repo with no `origin` remote gets a stable workspace id. `codewhale plugin list` is an alias of `plugins status`. A heavy command runs after 30 seconds instead of waiting forever for an admission slot. An MCP registration 4xx names the status and the manual client-id path.
|
||||
- **Wafer** is a selectable OpenAI-compatible host at `https://pass.wafer.ai/v1`. The public model list is the catalog; the default is `GLM-5.3`. Inference uses `WAFER_API_KEY`.
|
||||
- **Provider defaults follow the provider's own model list.** A same-line successor (`grok-4.6` to `grok-4.7`, `muse-spark-1.2` to `muse-spark-1.3`) replaces a stale pin. A different line, such as GPT-6 beside GPT-5.6, does not. The provider pane shows one current model per line and spreads a gateway list across authors, instead of filling the screen with every Claude snapshot.
|
||||
- **Turns no longer fail on a cost bookkeeping error.** When Codewhale could not
|
||||
|
||||
@@ -235,8 +235,9 @@ enum Commands {
|
||||
Install(TuiPassthroughArgs),
|
||||
#[command(
|
||||
about = "Inspect or restage reviewed plugin bundles",
|
||||
alias = "plugin",
|
||||
override_usage = "codewhale plugins <COMMAND>",
|
||||
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot"
|
||||
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot"
|
||||
)]
|
||||
Plugins(TuiPassthroughArgs),
|
||||
/// Bootstrap MCP config and/or skills directories.
|
||||
|
||||
@@ -568,7 +568,7 @@ impl Engine {
|
||||
{
|
||||
stream_error.get_or_insert_with(|| "RLM usage settlement failed".to_string());
|
||||
}
|
||||
let mut response_stream_failed = steer_preempted || stream_error.is_some();
|
||||
let response_stream_failed = steer_preempted || stream_error.is_some();
|
||||
progress.turn_error = progress.turn_error.take().or(stream_error.clone());
|
||||
turn.stop_diagnostics
|
||||
.observe_provider_response(stop_reason.as_deref(), tool_uses.len());
|
||||
@@ -634,9 +634,7 @@ impl Engine {
|
||||
.as_ref()
|
||||
.is_some_and(|dispatch| !dispatch.settlement_acknowledged())
|
||||
{
|
||||
stream_error.get_or_insert_with(|| "Child usage settlement failed".to_string());
|
||||
response_stream_failed = true;
|
||||
progress.turn_error = progress.turn_error.take().or(stream_error.clone());
|
||||
tracing::warn!("child usage settlement was not acknowledged; the turn continues");
|
||||
}
|
||||
drop(child_dispatch.take());
|
||||
if let Some(job) = child_job.as_ref() {
|
||||
|
||||
@@ -1846,17 +1846,55 @@ pub fn dynamic_registration_unavailable(server_name: &str, url: &str) -> bool {
|
||||
.contains(&(server_name.to_string(), url.to_string()))
|
||||
}
|
||||
|
||||
const MAX_REGISTRATION_DETAIL_CHARS: usize = 400;
|
||||
|
||||
fn registration_status_rejection(message: &str) -> Option<String> {
|
||||
let trimmed = message.trim();
|
||||
let status = trimmed.strip_prefix("HTTP ")?.split_whitespace().next()?;
|
||||
let code = status.parse::<u16>().ok()?;
|
||||
if !(400..500).contains(&code) {
|
||||
return None;
|
||||
}
|
||||
let mut detail: String = trimmed
|
||||
.chars()
|
||||
.take(MAX_REGISTRATION_DETAIL_CHARS)
|
||||
.collect();
|
||||
if detail.chars().count() < trimmed.chars().count() {
|
||||
detail.push('\u{2026}');
|
||||
}
|
||||
Some(detail)
|
||||
}
|
||||
|
||||
fn error_is_dynamic_registration_unsupported(error: &anyhow::Error) -> bool {
|
||||
matches!(
|
||||
error.downcast_ref::<AuthError>(),
|
||||
Some(AuthError::RegistrationFailed(message))
|
||||
if message.contains("Dynamic client registration not supported")
|
||||
|| registration_status_rejection(message).is_some()
|
||||
)
|
||||
}
|
||||
|
||||
fn dynamic_registration_failure(error: &anyhow::Error) -> String {
|
||||
error
|
||||
.downcast_ref::<AuthError>()
|
||||
.and_then(|err| match err {
|
||||
AuthError::RegistrationFailed(message) => Some(message.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.map_or_else(
|
||||
|| "client registration is unavailable".to_string(),
|
||||
|message| registration_status_rejection(message).unwrap_or_else(|| message.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn dynamic_registration_guidance() -> &'static str {
|
||||
"Configure `oauth.client_metadata_url` for a hosted client document, or `oauth.client_id` with its exact `oauth.issuer`, then log in again. Alternatively set `bearer_token_env_var` to an environment variable holding a least-privilege personal access token and run `/mcp reload`"
|
||||
}
|
||||
|
||||
pub fn dynamic_registration_unavailable_hint(server_name: &str) -> String {
|
||||
format!(
|
||||
"MCP server '{server_name}' cannot complete an OAuth login: its authorization server offers no usable client registration. Configure `oauth.client_metadata_url` for a hosted client document, or `oauth.client_id` with its exact `oauth.issuer`, then log in again. Alternatively set `bearer_token_env_var` to an environment variable holding a least-privilege personal access token and run `/mcp reload`"
|
||||
"MCP server '{server_name}' cannot complete an OAuth login: its authorization server offers no usable client registration. {}",
|
||||
dynamic_registration_guidance()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2031,7 +2069,11 @@ impl OauthLoginFlow {
|
||||
}
|
||||
Err(error) if error_is_dynamic_registration_unsupported(&error) => {
|
||||
note_dynamic_registration(server_name, server_url, false);
|
||||
return Err(error.context(dynamic_registration_unavailable_hint(server_name)));
|
||||
return Err(anyhow!(
|
||||
"MCP server '{server_name}' cannot complete an OAuth login: {}. {}",
|
||||
dynamic_registration_failure(&error),
|
||||
dynamic_registration_guidance()
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ pub const DEFAULT_HEAVY_COMMAND_LIMIT: usize = 2;
|
||||
const MAX_HEAVY_COMMAND_LIMIT: usize = 16;
|
||||
const ADMISSION_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
const ADMISSION_MAX_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// When the host free-RAM fraction drops to/below these thresholds the
|
||||
/// effective heavy-command admission limit tightens so a saturated host stops
|
||||
/// admitting new link graphs (#4864 req 7). Values are deliberately generous
|
||||
@@ -91,7 +93,7 @@ struct HeavyPermitSlot {
|
||||
/// A held cross-process heavy-command permit.
|
||||
#[derive(Debug)]
|
||||
pub struct HeavyCommandPermit {
|
||||
_slot: HeavyPermitSlot,
|
||||
_slot: Option<HeavyPermitSlot>,
|
||||
queued_for: Duration,
|
||||
limit: usize,
|
||||
memory_pressure: MemoryPressure,
|
||||
@@ -254,7 +256,7 @@ async fn acquire_heavy_command_permit_at(
|
||||
match try_lock_slot(&path) {
|
||||
Ok(Some(slot)) => {
|
||||
return Ok(HeavyCommandPermit {
|
||||
_slot: slot,
|
||||
_slot: Some(slot),
|
||||
queued_for: started.elapsed(),
|
||||
limit,
|
||||
memory_pressure: pressure,
|
||||
@@ -269,6 +271,20 @@ async fn acquire_heavy_command_permit_at(
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(ADMISSION_POLL_INTERVAL).await;
|
||||
if started.elapsed() >= ADMISSION_MAX_WAIT {
|
||||
tracing::warn!(
|
||||
waited_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
effective_slots = effective,
|
||||
?pressure,
|
||||
"heavy command admission wait expired; running without a permit"
|
||||
);
|
||||
return Ok(HeavyCommandPermit {
|
||||
_slot: None,
|
||||
queued_for: started.elapsed(),
|
||||
limit,
|
||||
memory_pressure: pressure,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +377,15 @@ fn host_memory_free_fraction() -> Option<f64> {
|
||||
.parse::<u64>()
|
||||
.ok()?;
|
||||
let stats = run_capture("/usr/bin/vm_stat", &[])?;
|
||||
let free_pages = memory_pages_from_vm_stat(&stats, &["Pages free:", "Pages inactive:"])?;
|
||||
let free_pages = memory_pages_from_vm_stat(
|
||||
&stats,
|
||||
&[
|
||||
"Pages free:",
|
||||
"Pages inactive:",
|
||||
"Pages speculative:",
|
||||
"Pages purgeable:",
|
||||
],
|
||||
)?;
|
||||
let free_bytes = free_pages.checked_mul(page_size)?;
|
||||
(total > 0).then(|| (free_bytes as f64 / total as f64).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
@@ -71,15 +71,26 @@ impl NativeMemoryStore {
|
||||
.arg(workspace)
|
||||
.args(["config", "--get", "remote.origin.url"])
|
||||
.output()?;
|
||||
if !out.status.success() {
|
||||
return Ok(None);
|
||||
if out.status.success() {
|
||||
let value = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
if !value.is_empty() {
|
||||
return Ok(Some(policy::sha256(value.as_bytes())));
|
||||
}
|
||||
}
|
||||
let value = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
Ok(if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(policy::sha256(value.as_bytes()))
|
||||
})
|
||||
let toplevel = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace)
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|out| out.status.success())
|
||||
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from);
|
||||
let anchor = toplevel
|
||||
.or_else(|| workspace.canonicalize().ok())
|
||||
.unwrap_or_else(|| workspace.to_path_buf());
|
||||
Ok(Some(policy::sha256(anchor.as_os_str().as_encoded_bytes())))
|
||||
}
|
||||
pub fn open_structured(&self) -> Result<Store> {
|
||||
Ok(Store::open(self.index_path())?)
|
||||
@@ -280,9 +291,9 @@ impl NativeMemoryStore {
|
||||
})
|
||||
}
|
||||
/// Session-scoped structured access for engine boundaries. Scopes are
|
||||
/// owner + workspace (when the repository has an origin) + this session's
|
||||
/// context scope, so receipts are attributable to the session that made
|
||||
/// them and nothing else can write into them.
|
||||
/// owner + workspace + this session's context scope, so receipts are
|
||||
/// attributable to the session that made them and nothing else can write
|
||||
/// into them.
|
||||
fn session_binding(
|
||||
&self,
|
||||
workspace: &Path,
|
||||
|
||||
@@ -6336,15 +6336,11 @@ mod tests {
|
||||
assert!(!id.is_empty());
|
||||
assert_eq!(id, memory.workspace_id(tmp.path()).expect("stable id"));
|
||||
|
||||
// A plain directory without git origin preserves the established error.
|
||||
// A plain directory without a git origin still gets a stable identity.
|
||||
let plain = TempDir::new().unwrap();
|
||||
let err = memory
|
||||
.workspace_id(plain.path())
|
||||
.expect_err("missing origin");
|
||||
assert_eq!(
|
||||
err,
|
||||
"workspace memory requires a git repository with an origin"
|
||||
);
|
||||
let plain_id = memory.workspace_id(plain.path()).expect("workspace id");
|
||||
assert!(!plain_id.is_empty());
|
||||
assert_ne!(plain_id, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6614,18 +6610,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_adapter_preserves_workspace_delete_error_text() {
|
||||
fn memory_adapter_deletes_workspace_without_an_origin() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut app = memory_test_app(&tmp, true);
|
||||
let mut bundle = app.command_contexts();
|
||||
let memory = bundle.parts().memory.expect("memory facet");
|
||||
let err = memory
|
||||
memory
|
||||
.delete_workspace(tmp.path())
|
||||
.expect_err("missing origin");
|
||||
assert_eq!(
|
||||
err,
|
||||
"workspace memory requires a git repository with an origin"
|
||||
);
|
||||
.expect("plain folder still has a workspace id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -271,7 +271,7 @@ enum Commands {
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
enum PluginsCommand {
|
||||
#[command(about = "Inspect plugin status and diagnostics")]
|
||||
#[command(about = "Inspect plugin status and diagnostics", alias = "list")]
|
||||
Status {
|
||||
#[arg(value_name = "NAME")]
|
||||
name: Option<String>,
|
||||
|
||||
Reference in new issue
Block a user