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:
CodeWhale Bot committed 2026-11-07 21:43:33 -08:00
1 parent 5de1da95f4
commit 3c7a43252c
8 files changed
+107 -37

No files matched your search

+2
View File
@@ -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
+2 -1
View File
@@ -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() {
+44 -2
View File
@@ -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),
};
+27 -3
View File
@@ -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))
}
+22 -11
View File
@@ -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,
+7 -15
View File
@@ -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]
+1 -1
View File
@@ -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>,