Skip to content

Commit 5677b82

Browse files
committed
Auto-merge upstream instructkr/claw-code
2 parents 74f24f0 + ee92f13 commit 5677b82

5 files changed

Lines changed: 227 additions & 23 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
310310
21. **Resumed `/status` JSON parity gap** — dogfooding shows fresh `claw status --output-format json` now emits structured JSON, but resumed slash-command status still leaks through a text-shaped path in at least one dispatch path. Local CI-equivalent repro fails `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs::resumed_status_command_emits_structured_json_when_requested` with `expected value at line 1 column 1`, so resumed automation can receive text where JSON was explicitly requested. **Action:** unify fresh vs resumed `/status` rendering through one output-format contract and add regression coverage so resumed JSON output is guaranteed valid.
311311
22. **Opaque failure surface for session/runtime crashes** — repeated dogfood-facing failures can currently collapse to generic wrappers like `Something went wrong while processing your request. Please try again, or use /new to start a fresh session.` without exposing whether the fault was provider auth, session corruption, slash-command dispatch, render failure, or transport/runtime panic. This blocks fast self-recovery and turns actionable clawability bugs into blind retries. **Action:** preserve a short user-safe failure class (`provider_auth`, `session_load`, `command_dispatch`, `render`, `runtime_panic`, etc.), attach a local trace/session id, and ensure operators can jump from the chat-visible error to the exact failure log quickly.
312312
23. **`doctor --output-format json` check-level structure gap** — direct dogfooding shows `claw doctor --output-format json` exposes `has_failures` at the top level, but individual check results (`auth`, `config`, `workspace`, `sandbox`, `system`) are buried inside flat prose fields like `message` / `report`. That forces claws to string-scrape human text instead of consuming stable machine-readable diagnostics. **Action:** emit structured per-check JSON (`name`, `status`, `summary`, `details`, and relevant typed fields such as sandbox fallback reason) while preserving the current human-readable report for text mode.
313+
24. **Plugin lifecycle init/shutdown test flakes under workspace-parallel execution** — dogfooding surfaced that `build_runtime_runs_plugin_lifecycle_init_and_shutdown` can fail under `cargo test --workspace` while passing in isolation because sibling tests race on tempdir-backed shell init script paths. This is test brittleness rather than a code-path regression, but it still destabilizes CI confidence and wastes diagnosis cycles. **Action:** isolate temp resources per test robustly (unique dirs + no shared cwd assumptions), audit cleanup timing, and add a regression guard so the plugin lifecycle test remains stable under parallel workspace execution.
313314
**P3 — Swarm efficiency**
314315
13. Swarm branch-lock protocol — **done**: `branch_lock::detect_branch_lock_collisions()` now detects same-branch/same-scope and nested-module collisions before parallel lanes drift into duplicate implementation
315316
14. Commit provenance / worktree-aware push events — **done**: lane event provenance now includes branch/worktree/superseded/canonical lineage metadata, and manifest persistence de-dupes superseded commit events before downstream consumers render them

rust/crates/api/src/error.rs

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ use std::env::VarError;
22
use std::fmt::{Display, Formatter};
33
use std::time::Duration;
44

5+
const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
6+
"something went wrong while processing your request",
7+
"please try again, or use /new to start a fresh session",
8+
];
9+
510
#[derive(Debug)]
611
pub enum ApiError {
712
MissingCredentials {
@@ -25,6 +30,7 @@ pub enum ApiError {
2530
status: reqwest::StatusCode,
2631
error_type: Option<String>,
2732
message: Option<String>,
33+
request_id: Option<String>,
2834
body: String,
2935
retryable: bool,
3036
},
@@ -65,6 +71,66 @@ impl ApiError {
6571
| Self::BackoffOverflow { .. } => false,
6672
}
6773
}
74+
75+
#[must_use]
76+
pub fn request_id(&self) -> Option<&str> {
77+
match self {
78+
Self::Api { request_id, .. } => request_id.as_deref(),
79+
Self::RetriesExhausted { last_error, .. } => last_error.request_id(),
80+
Self::MissingCredentials { .. }
81+
| Self::ContextWindowExceeded { .. }
82+
| Self::ExpiredOAuthToken
83+
| Self::Auth(_)
84+
| Self::InvalidApiKeyEnv(_)
85+
| Self::Http(_)
86+
| Self::Io(_)
87+
| Self::Json(_)
88+
| Self::InvalidSseFrame(_)
89+
| Self::BackoffOverflow { .. } => None,
90+
}
91+
}
92+
93+
#[must_use]
94+
pub fn safe_failure_class(&self) -> &'static str {
95+
match self {
96+
Self::RetriesExhausted { .. } => "provider_retry_exhausted",
97+
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
98+
"provider_auth"
99+
}
100+
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
101+
Self::ContextWindowExceeded { .. } => "context_window",
102+
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
103+
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
104+
Self::Api { .. } => "provider_error",
105+
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
106+
"provider_transport"
107+
}
108+
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json(_) => "runtime_io",
109+
}
110+
}
111+
112+
#[must_use]
113+
pub fn is_generic_fatal_wrapper(&self) -> bool {
114+
match self {
115+
Self::Api { message, body, .. } => {
116+
message
117+
.as_deref()
118+
.is_some_and(looks_like_generic_fatal_wrapper)
119+
|| looks_like_generic_fatal_wrapper(body)
120+
}
121+
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
122+
Self::MissingCredentials { .. }
123+
| Self::ContextWindowExceeded { .. }
124+
| Self::ExpiredOAuthToken
125+
| Self::Auth(_)
126+
| Self::InvalidApiKeyEnv(_)
127+
| Self::Http(_)
128+
| Self::Io(_)
129+
| Self::Json(_)
130+
| Self::InvalidSseFrame(_)
131+
| Self::BackoffOverflow { .. } => false,
132+
}
133+
}
68134
}
69135

70136
impl Display for ApiError {
@@ -102,14 +168,24 @@ impl Display for ApiError {
102168
status,
103169
error_type,
104170
message,
171+
request_id,
105172
body,
106173
..
107-
} => match (error_type, message) {
108-
(Some(error_type), Some(message)) => {
109-
write!(f, "api returned {status} ({error_type}): {message}")
174+
} => {
175+
if let (Some(error_type), Some(message)) = (error_type, message) {
176+
write!(f, "api returned {status} ({error_type})")?;
177+
if let Some(request_id) = request_id {
178+
write!(f, " [trace {request_id}]")?;
179+
}
180+
write!(f, ": {message}")
181+
} else {
182+
write!(f, "api returned {status}")?;
183+
if let Some(request_id) = request_id {
184+
write!(f, " [trace {request_id}]")?;
185+
}
186+
write!(f, ": {body}")
110187
}
111-
_ => write!(f, "api returned {status}: {body}"),
112-
},
188+
}
113189
Self::RetriesExhausted {
114190
attempts,
115191
last_error,
@@ -151,3 +227,57 @@ impl From<VarError> for ApiError {
151227
Self::InvalidApiKeyEnv(value)
152228
}
153229
}
230+
231+
fn looks_like_generic_fatal_wrapper(text: &str) -> bool {
232+
let lowered = text.to_ascii_lowercase();
233+
GENERIC_FATAL_WRAPPER_MARKERS
234+
.iter()
235+
.any(|marker| lowered.contains(marker))
236+
}
237+
238+
#[cfg(test)]
239+
mod tests {
240+
use super::ApiError;
241+
242+
#[test]
243+
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
244+
let error = ApiError::Api {
245+
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
246+
error_type: Some("api_error".to_string()),
247+
message: Some(
248+
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
249+
.to_string(),
250+
),
251+
request_id: Some("req_jobdori_123".to_string()),
252+
body: String::new(),
253+
retryable: true,
254+
};
255+
256+
assert!(error.is_generic_fatal_wrapper());
257+
assert_eq!(error.safe_failure_class(), "provider_internal");
258+
assert_eq!(error.request_id(), Some("req_jobdori_123"));
259+
assert!(error.to_string().contains("[trace req_jobdori_123]"));
260+
}
261+
262+
#[test]
263+
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
264+
let error = ApiError::RetriesExhausted {
265+
attempts: 3,
266+
last_error: Box::new(ApiError::Api {
267+
status: reqwest::StatusCode::BAD_GATEWAY,
268+
error_type: Some("api_error".to_string()),
269+
message: Some(
270+
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
271+
.to_string(),
272+
),
273+
request_id: Some("req_nested_456".to_string()),
274+
body: String::new(),
275+
retryable: true,
276+
}),
277+
};
278+
279+
assert!(error.is_generic_fatal_wrapper());
280+
assert_eq!(error.safe_failure_class(), "provider_retry_exhausted");
281+
assert_eq!(error.request_id(), Some("req_nested_456"));
282+
}
283+
}

rust/crates/api/src/providers/anthropic.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
808808
return Ok(response);
809809
}
810810

811+
let request_id = request_id_from_headers(response.headers());
811812
let body = response.text().await.unwrap_or_else(|_| String::new());
812813
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
813814
let retryable = is_retryable_status(status);
@@ -820,6 +821,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
820821
message: parsed_error
821822
.as_ref()
822823
.map(|error| error.error.message.clone()),
824+
request_id,
823825
body,
824826
retryable,
825827
})

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
906906
return Ok(response);
907907
}
908908

909+
let request_id = request_id_from_headers(response.headers());
909910
let body = response.text().await.unwrap_or_default();
910911
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
911912
let retryable = is_retryable_status(status);
@@ -918,6 +919,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
918919
message: parsed_error
919920
.as_ref()
920921
.and_then(|error| error.error.message.clone()),
922+
request_id,
921923
body,
922924
retryable,
923925
})

rust/crates/rusty-claude-cli/src/main.rs

Lines changed: 87 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5239,6 +5239,7 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
52395239
struct AnthropicRuntimeClient {
52405240
runtime: tokio::runtime::Runtime,
52415241
client: AnthropicClient,
5242+
session_id: String,
52425243
model: String,
52435244
enable_tools: bool,
52445245
emit_output: bool,
@@ -5262,6 +5263,7 @@ impl AnthropicRuntimeClient {
52625263
client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
52635264
.with_base_url(api::read_base_url())
52645265
.with_prompt_cache(PromptCache::new(session_id)),
5266+
session_id: session_id.to_string(),
52655267
model,
52665268
enable_tools,
52675269
emit_output,
@@ -5301,11 +5303,13 @@ impl ApiClient for AnthropicRuntimeClient {
53015303
};
53025304

53035305
self.runtime.block_on(async {
5304-
let mut stream = self
5305-
.client
5306-
.stream_message(&message_request)
5307-
.await
5308-
.map_err(|error| RuntimeError::new(error.to_string()))?;
5306+
let mut stream =
5307+
self.client
5308+
.stream_message(&message_request)
5309+
.await
5310+
.map_err(|error| {
5311+
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
5312+
})?;
53095313
let mut stdout = io::stdout();
53105314
let mut sink = io::sink();
53115315
let out: &mut dyn Write = if self.emit_output {
@@ -5319,11 +5323,9 @@ impl ApiClient for AnthropicRuntimeClient {
53195323
let mut pending_tool: Option<(String, String, String)> = None;
53205324
let mut saw_stop = false;
53215325

5322-
while let Some(event) = stream
5323-
.next_event()
5324-
.await
5325-
.map_err(|error| RuntimeError::new(error.to_string()))?
5326-
{
5326+
while let Some(event) = stream.next_event().await.map_err(|error| {
5327+
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
5328+
})? {
53275329
match event {
53285330
ApiStreamEvent::MessageStart(start) => {
53295331
for block in start.message.content {
@@ -5418,14 +5420,33 @@ impl ApiClient for AnthropicRuntimeClient {
54185420
..message_request.clone()
54195421
})
54205422
.await
5421-
.map_err(|error| RuntimeError::new(error.to_string()))?;
5423+
.map_err(|error| {
5424+
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
5425+
})?;
54225426
let mut events = response_to_events(response, out)?;
54235427
push_prompt_cache_record(&self.client, &mut events);
54245428
Ok(events)
54255429
})
54265430
}
54275431
}
54285432

5433+
fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String {
5434+
if error.is_generic_fatal_wrapper() {
5435+
let mut qualifiers = vec![format!("session {session_id}")];
5436+
if let Some(request_id) = error.request_id() {
5437+
qualifiers.push(format!("trace {request_id}"));
5438+
}
5439+
format!(
5440+
"{} ({}): {}",
5441+
error.safe_failure_class(),
5442+
qualifiers.join(", "),
5443+
error
5444+
)
5445+
} else {
5446+
error.to_string()
5447+
}
5448+
}
5449+
54295450
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
54305451
summary
54315452
.assistant_messages
@@ -6424,18 +6445,19 @@ mod tests {
64246445
format_permissions_report, format_permissions_switch_report, format_pr_report,
64256446
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
64266447
format_ultraplan_report, format_unknown_slash_command,
6427-
format_unknown_slash_command_message, normalize_permission_mode, parse_args,
6428-
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
6429-
permission_policy, print_help_to, push_output_block, render_config_report,
6430-
render_diff_report, render_diff_report_for, render_memory_report, render_repl_help,
6431-
render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events,
6448+
format_unknown_slash_command_message, format_user_visible_api_error,
6449+
normalize_permission_mode, parse_args, parse_git_status_branch,
6450+
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
6451+
print_help_to, push_output_block, render_config_report, render_diff_report,
6452+
render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage,
6453+
resolve_model_alias, resolve_session_reference, response_to_events,
64326454
resume_supported_slash_commands, run_resume_command,
64336455
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
64346456
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
64356457
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
64366458
SlashCommand, StatusUsage, DEFAULT_MODEL,
64376459
};
6438-
use api::{MessageResponse, OutputContentBlock, Usage};
6460+
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
64396461
use plugins::{
64406462
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
64416463
};
@@ -6475,12 +6497,59 @@ mod tests {
64756497
.expect("plugin tool registry should build")
64766498
}
64776499

6500+
#[test]
6501+
fn opaque_provider_wrapper_surfaces_failure_class_session_and_trace() {
6502+
let error = ApiError::Api {
6503+
status: "500".parse().expect("status"),
6504+
error_type: Some("api_error".to_string()),
6505+
message: Some(
6506+
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
6507+
.to_string(),
6508+
),
6509+
request_id: Some("req_jobdori_789".to_string()),
6510+
body: String::new(),
6511+
retryable: true,
6512+
};
6513+
6514+
let rendered = format_user_visible_api_error("session-issue-22", &error);
6515+
assert!(rendered.contains("provider_internal"));
6516+
assert!(rendered.contains("session session-issue-22"));
6517+
assert!(rendered.contains("trace req_jobdori_789"));
6518+
}
6519+
6520+
#[test]
6521+
fn retry_exhaustion_uses_retry_failure_class_for_generic_provider_wrapper() {
6522+
let error = ApiError::RetriesExhausted {
6523+
attempts: 3,
6524+
last_error: Box::new(ApiError::Api {
6525+
status: "502".parse().expect("status"),
6526+
error_type: Some("api_error".to_string()),
6527+
message: Some(
6528+
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
6529+
.to_string(),
6530+
),
6531+
request_id: Some("req_jobdori_790".to_string()),
6532+
body: String::new(),
6533+
retryable: true,
6534+
}),
6535+
};
6536+
6537+
let rendered = format_user_visible_api_error("session-issue-22", &error);
6538+
assert!(rendered.contains("provider_retry_exhausted"), "{rendered}");
6539+
assert!(rendered.contains("session session-issue-22"));
6540+
assert!(rendered.contains("trace req_jobdori_790"));
6541+
}
6542+
64786543
fn temp_dir() -> PathBuf {
6544+
use std::sync::atomic::{AtomicU64, Ordering};
6545+
6546+
static COUNTER: AtomicU64 = AtomicU64::new(0);
64796547
let nanos = SystemTime::now()
64806548
.duration_since(UNIX_EPOCH)
64816549
.expect("time should be after epoch")
64826550
.as_nanos();
6483-
std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}"))
6551+
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
6552+
std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}-{unique}"))
64846553
}
64856554

64866555
fn git(args: &[&str], cwd: &Path) {

0 commit comments

Comments
 (0)