[P1] unwrap()/expect() in recovery hot paths (force_write, tool_call_recovery) can panic #42
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_shrimp#42
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
File-creation/path code in
force_write.rsand JSON parsing intool_call_recovery.rsuseunwrap()on fallible operations. A broken workspace, permission error, or malformed model tool-args can panic the recovery path instead of escalating gracefully.Evidence
crates/hero_shrimp_engine/src/agent_core/agent/force_write.rs(multiple.unwrap()).crates/hero_shrimp_engine/src/agent_core/agent/tool_call_recovery.rs(serde_json::from_str(...).unwrap()).Proposed fix
Return typed errors + escalate; a recovery routine must never crash the run.
Filed from a comparative audit of Hero Shrimp vs Qwen-Code / kimi-cli / picoclaw (2026-05-23). Severity in title: P0=correctness/trust, P1=reliability/UX, P2=cleanup.
Implementation Spec for Issue #42
Objective
Eliminate every
unwrap()andexpect()call on a fallible operation in the two recovery hot paths (force_write.rsandtool_call_recovery.rs) so that a broken workspace, a permission error, or malformed model tool-args degrade gracefully (no-op + trace event) instead of panicking the run.Requirements
.unwrap()/.expect(...)on a fallible operation may remain in the production (non-#[cfg(test)]) code offorce_write.rsortool_call_recovery.rs.false,None,Vec::new(), or unchanged input) plus atracing::warn!for observability.no_tool_calls.rs::handle_no_tool_callscallingforce_write_guard,tool_call_recovery_module.rs::lift_recovered_tool_callscalling the recovery helpers) treat these functions as infallible; converting them toResultwould ripple through the loop and is out of scope. "Escalate gracefully" here means: silently no-op, log the failure, and let the existing escalation machinery downstream (stuck_escalation::escalate_if_ending_incomplete, the zero-deliverables proof_run check, the iteration budget) take over.unwrap()s inside#[cfg(test)] mod testsblocks are acceptable test code and are out of scope for this issue).Findings From Codebase Audit
The issue body says
force_write.rshas "multiple.unwrap()" andtool_call_recovery.rshasserde_json::from_str(...).unwrap(). Current state ondevelopment:force_write.rs: ZERO productionunwrap()/expect()calls. All 20unwrap()occurrences (lines 210, 213, 224, 233, 234, 238, 250, 262, 274, 287, 295, 307, 326, 374, 398, 413, 430, 435, 440, 451) are inside#[cfg(test)] mod tests(starts at line 198).tool_call_recovery.rs: ZEROserde_json::from_str(...).unwrap()in production. The production code at line 492 already uses.unwrap_or(serde_json::Value::Object(serde_json::Map::new())). The only production-codeexpect()calls are on regex compilation at lines 457, 480, 484 — these are arguably infallible-by-construction (constant regex literals), but the issue's intent ("a recovery routine must never crash the run") still applies: a panic on regex compile is the only remaining panic path in this file.So the substantive production-code work is: replace the three regex
expect()calls intool_call_recovery.rswith non-panicking fallbacks; everything else is verification that the test-onlyunwrap()s remain test-only.Files to Modify
crates/hero_shrimp_engine/src/agent_core/agent/tool_call_recovery.rs— replace the three productionexpect()calls onRegex::new(...)at lines 457, 480, 484 withOnceLock<Option<Regex>>patterns that degrade gracefully on regex compilation failure.crates/hero_shrimp_engine/src/agent_core/agent/force_write.rs— verify only (no production changes needed). All existingunwrap()s are inside#[cfg(test)].Implementation Plan
Step 1: Make
strip_bracket_tool_callsregex-failure safeFiles:
crates/hero_shrimp_engine/src/agent_core/agent/tool_call_recovery.rsCurrent code (lines 453-470):
Subtasks:
Regex::new(...).expect(...)on lines 456-457 with aOnceLock<Option<Regex>>pattern that, onErr, logs viatracing::warn!and returns(content.to_string(), Vec::new())— semantically equivalent to "no bracket blocks found", which is the documented neutral fallback.static BRACKET_RE: std::sync::OnceLock<Option<Regex>>so repeated calls do not re-compile the pattern.Replacement pattern:
Dependencies: none.
Step 2: Make
normalize_arrow_syntaxregex-failure safeFiles:
crates/hero_shrimp_engine/src/agent_core/agent/tool_call_recovery.rsCurrent code (lines 477-501):
Subtasks:
.expect(...)calls on lines 480 and 484 withOnceLock<Option<Regex>>initializers identical to Step 1.tracing::warn!andreturn None.Noneis already the documented fallback for "shape didn't match" (line 475-476), so callers (lift_recovered_tool_callsat line 48 oftool_call_recovery_module.rs) already handle it by falling through to the next recovery mechanism.Replacement pattern:
Dependencies: none (can be done alongside Step 1).
Step 3: Audit
force_write.rsFiles:
crates/hero_shrimp_engine/src/agent_core/agent/force_write.rsSubtasks:
grep -n 'unwrap()\|expect(' force_write.rsthat every match is at line >= 198 (inside#[cfg(test)] mod tests).Dependencies: none.
Step 4: Verify behavior end-to-end
cargo test -p hero_shrimp_engine tool_call_recovery— every existing test must still pass with the regex changes, including theproptest!fuzz (lines 846-895) and the "never panics on adversarial input" suite.cargo test -p hero_shrimp_engine force_write— every existing test (includingscenario_*) must still pass.Dependencies: Steps 1 and 2 complete.
Acceptance Criteria
.unwrap()or.expect(...)remains intool_call_recovery.rsoutside#[cfg(test)] mod tests..unwrap()or.expect(...)remains inforce_write.rsoutside#[cfg(test)] mod tests(already true — confirm with grep).strip_bracket_tool_callsreturns(content.to_string(), Vec::new())instead of panicking if the bracket regex fails to compile.normalize_arrow_syntaxreturnsNoneinstead of panicking if either of its two regexes fails to compile.cargo test -p hero_shrimp_enginepasses.cargo build -p hero_shrimp_engineis clean — no new warnings.no_tool_calls.rs::handle_no_tool_calls,tool_call_recovery_module.rs::lift_recovered_tool_calls) compile unchanged — public signatures are NOT changed.Notes
anyhow::Resulteverywhere. The crate does not usethiserroror a custom error enum.force_write_guardreturningfalsecauses the caller to proceed tostuck_escalation::escalate_if_ending_incomplete(no_tool_calls.rsline 171);lift_recovered_tool_callsreturning a no-op falls through torecover_tool_calls_iter0(prompt-retry then fallback model) intool_call_recovery_module.rs. "Escalate gracefully" means returning the existing neutral value plus atracing::warn!; the downstream stages take it from there. Changing these signatures toResultwould ripple through the iteration loop and is out of scope for a P1 panic-hygiene fix.expect()panics are real: although the three regex literals are valid today and the panic path is unreachable in normal conditions, the issue explicitly says "a recovery routine must never crash the run", and the codebase's adversarial test suite (tool_call_recovery.rslines 696-707) already wraps calls instd::panic::catch_unwindto assert no-panic. Replacing.expect(...)withOnceLock<Option<Regex>>formalises that contract and pays the price (≈ 5 lines per regex) for guaranteed crash safety.OnceLockalso eliminates the previous per-callRegex::newallocation.Test Results
Branch:
development_recovery_panic_hygieneTargeted tests (the modules touched by this change)
cargo test -p hero_shrimp_engine --lib tool_call_recovery— 40 passed, 0 failedIncludes the property-based fuzz suite (
prop_repair_never_panics_*,prop_recover_never_panics_*) and the adversarial-input suite (recovery_never_panics_on_adversarial_input,recovery_never_panics_on_huge_and_nested_input).cargo test -p hero_shrimp_engine --lib force_write— 14 passed, 0 failedFull crate suite
cargo test -p hero_shrimp_engine— 1639 passed, 9 failedThe 9 failures are pre-existing on
developmentand unrelated to this change. They live inverification::runner::*,tools::external_cmd::*,tools::tool_catalog::verify::e2e_datetime_server::*, andtests::autonomy_*— all rely on spawning shell processes, opening sockets, or running isolated backends and fail in the local sandboxed environment regardless of branch. Verified by checking outdevelopmentand re-running the same suite (1639 passed / 9 failed, identical test names).Build
cargo build -p hero_shrimp_engine— clean, no new warnings.What changed
strip_bracket_tool_callsnow caches its regex inOnceLock<Option<Regex>>and returns the no-op fallback(content.to_string(), Vec::new())plus atracing::warn!if regex compilation ever fails.normalize_arrow_syntaxdoes the same for its two regexes and falls through toNone(its documented "shape didn't match" return) plus atracing::warn!on compile failure.force_write.rsrequired no production-code changes — allunwrap()s in that file are inside the#[cfg(test)] mod testsblock. Confirmed withgrep -n 'unwrap()|expect(' force_write.rs(every match at line >= 210, past the#[cfg(test)]boundary at line 198).Test Results
Branch:
development_recovery_panic_hygieneCommand:
cargo test -p hero_shrimp_engineTargeted tests (the changed surface)
agent_core::agent::tool_call_recovery(cargo test -p hero_shrimp_engine --lib tool_call_recovery)agent_core::agent::force_write(cargo test -p hero_shrimp_engine --lib force_write)Notable passing tests in scope:
strip_bracket_tool_calls_*(4 tests covering single-block, multi-block, no-markers, block-only)normalize_arrow_syntax_*and the upstreamrecovers_*paths that consume itrecovery_never_panics_on_huge_and_nested_input— the adversarialcatch_unwind-wrapped suiteprop_recover_never_panics_arbitrary_string,prop_recover_never_panics_arbitrary_bytes,prop_repair_never_panics_arbitrary_string,prop_repair_never_panics_json_soup,prop_recovered_calls_are_well_typed— proptest fuzzingFull crate run
cargo test -p hero_shrimp_engine --lib: 1639 passed, 9 failed, 1 ignored (31s).The 9 failures are all environment-related and pre-exist on
development:tools::external_cmd::tests::spawn_failing_command_returns_failure_with_exit_codesh: No such file or directorytools::external_cmd::tests::spawn_runs_a_real_command_and_captures_stdoutsh: No such file or directorytools::external_cmd::tests::spawn_timeout_returns_failure_not_hangsh: No such file or directoryverification::runner::tests::command_succeeds_decides_purely_on_exit_codeverification::runner::tests::command_runs_through_a_shell_so_cd_and_chaining_worktools::tool_catalog::verify::e2e_datetime_server::phase2_http_server_live_requestpython3not in PATHtools::tool_catalog::verify::e2e_datetime_server::phase3_edge_case_unknown_route_returns_404python3not in PATHtests::autonomy_auto_fallback_warns_when_no_isolated_backend_existsBubblewrapwhere the test expectedHosttests::autonomy_context_auto_selects_isolated_backendsBaseline verification: checking out
developmentand running the identicalcargo test -p hero_shrimp_engine --libreproduces the same 1639/9/1 numbers with the same failing test names. The regex-hardening changes in this branch introduce zero new failures.Build
cargo build -p hero_shrimp_engine: clean, no new warnings.Implementation Summary
Scope
P1 panic-hygiene fix for the two recovery hot paths called out in this issue. Pre-change audit revealed the panic surface in production code is narrower than the issue body suggested:
force_write.rs: no productionunwrap()/expect()— all 20 occurrences are inside#[cfg(test)] mod tests(boundary at line 198, first call at line 210).tool_call_recovery.rs: no productionserde_json::from_str(...).unwrap()— the call at line 492 already uses.unwrap_or(Value::Object(Map::new())). The only production panic surface was threeRegex::new(...).expect(...)calls on constant regex literals (lines 457, 480, 484).Files changed
crates/hero_shrimp_engine/src/agent_core/agent/tool_call_recovery.rs— replaced 3Regex::new(...).expect(...)calls withOnceLock<Option<Regex>>patterns. On compile failure each callsite now emitstracing::warn!(target: "tool_call_recovery", ...)and returns its documented neutral value:strip_bracket_tool_callsreturns(content.to_string(), Vec::new())(same as the "no markers found" branch).normalize_arrow_syntaxreturnsNone(same as the "shape didn't match" branch).As a side benefit the regexes are now compiled once per process via
OnceLockinstead of on every recovery turn.crates/hero_shrimp_engine/src/agent_core/agent/force_write.rs— no changes (audit confirmed allunwrap()s are test-only).Public API
Function signatures unchanged.
force_write_guard,strip_bracket_tool_calls,normalize_arrow_syntax,recover_tool_calls_from_content, andlift_recovered_tool_callsall keep their existing return types. Callers (no_tool_calls.rs::handle_no_tool_calls,tool_call_recovery_module.rs::lift_recovered_tool_calls) compile unchanged. Escalation continues through the existing downstream path (stuck_escalation::escalate_if_ending_incomplete, prompt-retry, fallback model).Test results
cargo test -p hero_shrimp_engine --lib tool_call_recovery— 40 passed, 0 failed (includes the property-based fuzz andrecovery_never_panics_on_adversarial_inputsuite).cargo test -p hero_shrimp_engine --lib force_write— 14 passed, 0 failed.cargo test -p hero_shrimp_engine— 1639 passed, 9 failed. The 9 failures are pre-existing ondevelopment(verified by re-running on a clean checkout ofdevelopment) and unrelated to this change — they live inverification::runner::*,tools::external_cmd::*,tools::tool_catalog::verify::e2e_datetime_server::*, andtests::autonomy_*, all of which spawn shell processes or open sockets and fail in the sandboxed local environment regardless of branch.cargo build -p hero_shrimp_engine— clean, no new warnings.Acceptance criteria
.unwrap()or.expect(...)remains intool_call_recovery.rsoutside#[cfg(test)] mod tests..unwrap()or.expect(...)remains inforce_write.rsoutside#[cfg(test)] mod tests(verified, already true).strip_bracket_tool_callsreturns(content.to_string(), Vec::new())instead of panicking on regex-compile failure.normalize_arrow_syntaxreturnsNoneinstead of panicking on regex-compile failure.cargo test -p hero_shrimp_engine --lib tool_call_recoveryandcargo test -p hero_shrimp_engine --lib force_writepass.cargo build -p hero_shrimp_engineclean.Notes
The regex panic path is unreachable in practice (the patterns are constant literals known to compile), so this change is defense-in-depth: it formalises the file's existing contract — codified in the adversarial test at
tool_call_recovery.rslines 696-707 that wraps the recovery path instd::panic::catch_unwind— that no input or environmental condition may panic the recovery routine.Branch:
development_recovery_panic_hygiene.Implementation Summary
Branch:
development_recovery_panic_hygieneChanges
Files modified
crates/hero_shrimp_engine/src/agent_core/agent/tool_call_recovery.rs— 39 insertions, 7 deletions.Files audited (no changes required)
crates/hero_shrimp_engine/src/agent_core/agent/force_write.rs— everyunwrap()/expect()(20 occurrences, lines 210–451) is inside#[cfg(test)] mod tests(starts at line 198). Production code is panic-free.What changed in
tool_call_recovery.rsThree production-code
.expect(...)calls onRegex::new(...)were the only remaining panic paths in the recovery hot path. All three were replaced withOnceLock<Option<Regex>>patterns that:tracing::warn!and returning the function's documented neutral fallback —(content.to_string(), Vec::new())forstrip_bracket_tool_calls,Nonefornormalize_arrow_syntax. Callers (lift_recovered_tool_calls,handle_no_tool_calls) already handle those fallback values by falling through to the next recovery mechanism (recover_tool_calls_iter0→ prompt-retry → fallback model →stuck_escalation::escalate_if_ending_incomplete).Specifically:
strip_bracket_tool_calls— theRegex::new(r"(?s)\[TOOL_CALL\](.*?)\[/TOOL_CALL\]").expect(...)at line 456 is now cached in astatic BRACKET_RE: std::sync::OnceLock<Option<Regex>>. On compile failure:tracing::warn!and return(content.to_string(), Vec::new())— semantically "no bracket blocks found".normalize_arrow_syntax— both regexes (tool_reat line 479 andargs_reat line 483) are cached in their ownOnceLock<Option<Regex>>. On compile failure of either:tracing::warn!and returnNone, which routes the caller to the next recovery path.Tests
tool_call_recovery(40 tests)force_write(14 tests)hero_shrimp_enginelibdevelopmentand are environment-related (missingsh, missingpython3, Bubblewrap detection differences). No new failures introduced.The adversarial suite —
recovery_never_panics_on_huge_and_nested_inputand the fourprop_*proptest fuzzers (prop_recover_never_panics_arbitrary_string,prop_recover_never_panics_arbitrary_bytes,prop_repair_never_panics_arbitrary_string,prop_repair_never_panics_json_soup) — continues to pass.Acceptance criteria
.unwrap()/.expect(...)remains intool_call_recovery.rsoutside#[cfg(test)] mod tests..unwrap()/.expect(...)remains inforce_write.rsoutside#[cfg(test)] mod tests.strip_bracket_tool_callsreturns(content.to_string(), Vec::new())instead of panicking on regex compile failure.normalize_arrow_syntaxreturnsNoneinstead of panicking on regex compile failure.cargo test -p hero_shrimp_engineintroduces no new failures vs.development.cargo build -p hero_shrimp_engineclean — no new warnings.Notes
OnceLockcaching also removes a per-recovery-turnRegex::newallocation.Pull request opened: #56
This PR implements the changes discussed in this issue.