Skip to content

feat(engine): add code execution failure categorization instrumentation - #2483

Merged
serrrfirat merged 5 commits into
stagingfrom
claude/audit-v2-engine-usage-I7dIz
Apr 16, 2026
Merged

serrrfirat merged 5 commits into
stagingfrom
claude/audit-v2-engine-usage-I7dIz

Conversation

@serrrfirat

@serrrfirat serrrfirat commented Apr 15, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Adds CodeExecutionFailure enum (7 variants: SyntaxError, RuntimeError, NameLookup, VmPanic, ResourceLimit, ToolError, OsDenied) to classify why code execution fails
  • Threads failure through CodeExecutionResult (replacing had_error: bool) and emits structured CodeExecutionFailed events for aggregate analysis of failure modes (Monty limitation vs LLM logic error vs tool dispatch failure)
  • Upgrades trace analysis to use structured events with severity escalation (VmPanic/ResourceLimit → Error), with backward-compatible fallback for pre-instrumentation threads

Caller audit (Err → Ok(failure=VmPanic) shift)

execute_code / execute_code_with_skills previously returned Err(EngineError::Effect) on VM panic; now returns Ok(CodeExecutionResult { failure: Some(VmPanic) }). The only production call site is orchestrator.rs:handle_execute_code_step which correctly dispatches on result.failure. Test-only callers in scripting.rs were also updated. No other callers exist.

Known limitation: mixed-era fallback

The trace analyzer's backward-compatible fallback (message-scraping for pre-instrumentation threads) is all-or-nothing: if a thread has any CodeExecutionFailed event, message-scraping is skipped entirely. Errors from pre-instrumentation steps in a mixed-era thread will go unreported. This is acceptable during the transition period since all new threads will have full instrumentation.

Test plan

  • 11 new unit tests for error classifier, code hash, and trace detection
  • cargo test -p ironclaw_engine --lib — 395 pass
  • cargo clippy --all --all-features — zero engine warnings

🤖 Generated with Claude Code

Add structured error classification to the v2 engine's CodeAct execution
path so we can measure whether REPL failures come from Monty VM
limitations, LLM logic errors, tool dispatch issues, or resource limits.

- Add CodeExecutionFailure enum (8 categories: SyntaxError, RuntimeError,
  NameLookup, VmPanic, ResourceLimit, ToolError, GatePause, OsDenied)
- Add CodeExecutionFailed event kind to EventKind for event sourcing
- Tag every error return path in scripting.rs with the correct category
- Emit CodeExecutionFailed events from the orchestrator on code errors
- Enhance trace analyzer to use structured events (with fallback to
  message-level pattern matching for pre-instrumentation threads)
- Expand fallback error patterns from 4 to 10 Python exception types
- Add 13 regression tests covering classification and trace detection

This enables aggregate queries like "what % of code failures are Monty
VM panics vs LLM generating bad Python" to inform runtime decisions.

https://claude.ai/code/session_018jFKVTjv1pkzwJobw43HwP
@github-actions github-actions Bot added size: L 200-499 changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Apr 15, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces structured instrumentation for code execution failures by adding a CodeExecutionFailure enum and emitting CodeExecutionFailed events during the execution process. The analyze_trace logic is updated to utilize these events for more precise issue reporting, while maintaining a fallback for older message-level patterns. Review feedback recommends replacing DefaultHasher with a stable hashing algorithm like XxHash64 to ensure consistency across compiler versions for persisted events, and suggests using to_ascii_lowercase() for error classification to follow project conventions.

Comment on lines +864 to +870
pub fn code_hash(code: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
code.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

DefaultHasher is not guaranteed to produce stable hashes across different compiler versions. Since these hashes are used for correlating events that may be persisted, this could lead to inconsistencies over time. It's recommended to use a hashing algorithm with a specified, stable output, such as XxHash64 from the twox-hash crate.

fn classify_runtime_error(error_msg: &str) -> crate::types::step::CodeExecutionFailure {
use crate::types::step::CodeExecutionFailure;

let lower = error_msg.to_lowercase();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Per project conventions, to_ascii_lowercase() should be used for case-insensitive comparisons on ASCII text. This avoids locale-dependent behavior and is more efficient. The error messages from Monty are expected to be ASCII.

Suggested change
let lower = error_msg.to_lowercase();
let lower = error_msg.to_ascii_lowercase();
References
  1. For case-insensitive comparisons, use to_ascii_lowercase() as it is the project convention, especially when the text is known to be ASCII.

if let Some(ref category) = result.failure_category {
let error_text: String = result.stdout.chars().take(500).collect();
let instrumentation_event = ThreadEvent::new(
thread.id,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium Severity — error_text takes first 500 chars of stdout instead of last

let error_text: String = result.stdout.chars().take(500).collect();

When code has print output before the error (e.g., a loop printing 100 items then hitting a NameError), the first 500 chars capture the print statements, not the error traceback. The project's own compact_output_metadata() in scripting.rs:88-96 takes the last N chars for exactly this reason.

Suggestion: Extract from the tail instead:

let chars: Vec<char> = result.stdout.chars().collect();
let start = chars.len().saturating_sub(500);
let error_text: String = chars[start..].iter().collect();

Note: the existing ActionFailed path at line 806 (result.stdout.chars().take(500)) has the same issue — worth fixing both with a shared tail-extract helper.

@@ -641,6 +656,9 @@ pub async fn execute_code_with_skills(
recursive_tokens,
final_answer: None,
had_error,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium Severity — GatePaused sets failure_category but the event is never emitted

At this return site, the code sets failure_category: Some(GatePause) but had_error is false (verified: every had_error = true in this function precedes an early return, so it's still false when GatePaused is reached).

In orchestrator.rs, the CodeExecutionFailed event emission is nested inside if result.had_error { ... }, so the GatePause category is silently discarded — it's dead data that will never appear in aggregate analysis.

Suggestion: Either:

  1. Don't set failure_category here (a gate pause is a suspension, not a failure), or
  2. Emit CodeExecutionFailed in orchestrator.rs based on failure_category.is_some() independently of had_error, so gate pauses are tracked in the instrumentation

@ilblackdragon

Copy link
Copy Markdown
Member

Review

Useful instrumentation, low blast radius. Three things worth fixing before merge: (1) error-text truncation captures the wrong end, (2) duration_ms: 0 is a misleading placeholder, (3) classifier is too loose and untested against real Monty errors.

Correctness

  • Error preview takes the wrong end of stdout. orchestrator.rs:~785 does result.stdout.chars().take(500).collect() — but tracebacks land at the end of stdout (after any print() output). For a script that prints 600 chars then errors, the recorded error field will contain only the prints and miss the actual failure. Use the last 500 chars, or extract the trailing Traceback (most recent call last): block.

  • duration_ms: 0 with comment "not timed at this layer". Half-instrumented telemetry is worse than absent telemetry — aggregations will silently average toward zero. Either thread an actual elapsed time through CodeExecutionResult (Instant::now() around the executor call), or drop the field until you can populate it.

  • VmPanic is declared but never emitted in the diff. No code path sets failure_category = Some(VmPanic). If the catch_unwind site lives in a section not in the diff, fine — otherwise this variant is dead. Confirm and add a test.

  • classify_runtime_error substring matches are too loose.

    • lower.contains(\"syntax\") will misclassify a runtime error whose message happens to include the word "syntax". Tighten to \"syntaxerror\" or anchor on :.
    • Check order matters: \"Resource limit: syntax error in ...\" would be tagged SyntaxError because syntax is checked first. Reorder so the most specific (resource-limit, OS-denied) precedes the most generic (syntax → runtime).
    • lower.contains(\"os\") && lower.contains(\"denied\") will fire on benign text like \"close: permission denied\" ("os" appears as a substring of many words).
  • had_error + failure_category semantic drift. Several Ok paths return had_error: true, failure_category: None. After this PR the canonical "did it fail" signal is ambiguous — orchestrator only emits CodeExecutionFailed when failure_category.is_some(), so a had_error: true / failure_category: None result fails silently from the instrumentation's perspective. Either make failure_category required when had_error == true (encode the invariant: enum CodeExecutionOutcome { Success(...), Failed(CodeExecutionFailure, ...) }), or default to a RuntimeError/Unknown variant.

  • Serialization vs Display casing mismatch. #[derive(Serialize)] on CodeExecutionFailure yields \"SyntaxError\" (PascalCase) on the wire, while Display yields \"syntax_error\" (snake_case). Trace category strings are code_syntax_error; persisted events are \"category\": \"SyntaxError\". Pick one (snake_case throughout via #[serde(rename_all = \"snake_case\")]).

Trace fallback

  • The fallback runs only when code_failures.is_empty(). A mixed-era thread (some pre-, some post-instrumentation events) silently skips the pre-instrumentation portion. Probably rare; worth a comment acknowledging it.
  • Fallback patterns now include the literal RuntimeError — but that string rarely appears verbatim in Monty stderr. Will likely fire on user-printed text more than real errors. Verify against a real corpus.

Persistence / forward-compat

  • New EventKind::CodeExecutionFailed variant: confirm the v2 persistence layer (libSQL + Postgres event tables) round-trips this without a migration. If events are stored as JSON in a column, deserialization in older binaries reading new rows will fail unless EventKind uses #[serde(other)] or similar — a real concern during rolling deploys.

Conventions

  • No .unwrap()/.expect() in production paths. ✅
  • Repeated long path crate::types::step::CodeExecutionFailure::* — add a use at file top.
  • code_hash uses DefaultHasher — deterministic across processes (unlike RandomState), safe for cross-process correlation. ✅ A u64-truncated hash has a ~2^32 birthday collision; fine for dedup, not for security. Add a doc note.
  • code_hash is pub — intentional for orchestrator to call; fine.

Test coverage

  • 11 new unit tests on classifier/hash/trace are good.
  • Missing: no test that the orchestrator actually emits the CodeExecutionFailed event. Per .claude/rules/testing.md ("Test Through the Caller"), the classifier is a predicate gating an event side effect — needs a test driving handle_execute_code_step and asserting both the broadcast tx and thread.events receive the event.
  • Missing: integration test against the real Monty runtime that classifies each enum variant from a real failure (SyntaxError from bad python, ResourceLimit from while True: pass, OsDenied from import os; os.listdir()). Substring matching over Monty's exact error text is brittle — pin it with real outputs so a Monty version bump that changes wording surfaces in CI rather than silently degrading the categorization.
  • No e2e test that surfaces aggregated failure counts the way analytics will consume them.

Suggestions (file:line)

  • executor/orchestrator.rs:~785 — slice the tail of stdout, not the head.
  • executor/orchestrator.rs:~790 — wrap the executor call with Instant::now() and pass real duration_ms through CodeExecutionResult. Or drop the field.
  • executor/scripting.rs:~835 — reorder classify_runtime_error checks (resource-limit, os-denied, syntax, fallthrough); tighten \"syntax\" → \"syntaxerror\"; replace \"os\" && \"denied\" heuristic with explicit error-class strings Monty actually emits.
  • types/step.rs:~140 — add #[serde(rename_all = \"snake_case\")] so wire and Display agree.
  • types/step.rs — consider replacing had_error: bool + failure_category: Option<_> with a sum type to make invalid states unrepresentable.
  • Add an integration test in crates/ironclaw_engine/tests/ driving a real failed exec end-to-end through the orchestrator against real Monty.

…ntation correctness (#2483)

- Replace `had_error: bool` + `failure_category: Option<_>` with single
  `failure: Option<CodeExecutionFailure>` field, making invalid states
  unrepresentable
- Remove `GatePause` variant (gate pauses are suspensions, not failures)
- Convert all 9 catch_unwind Err(_) paths to emit VmPanic instead of
  propagating EngineError, so panics get proper instrumentation events
- Fix error_text truncation: take last 500 chars (where tracebacks are),
  not first 500 chars (where print output is)
- Thread real `Instant::now()` timing through `duration_ms` instead of
  hardcoded 0
- Tighten `classify_runtime_error`: "syntax" → "syntaxerror", remove
  loose `"os" && "denied"` substring match, reorder checks
- Replace `DefaultHasher` with FNV-1a for stable cross-version hashing
- Add `#[serde(rename_all = "snake_case")]` so Serialize matches Display
- Add `#[serde(other)] Unknown` to EventKind for forward-compat
- Fix misleading CodeExecutionFailed docstring
- Add orchestrator caller test for CodeExecutionFailed event emission
- Use `to_ascii_lowercase()` per project convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size: XL 500+ changed lines and removed size: L 200-499 changed lines labels Apr 15, 2026
@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Addressed review feedback (88d3f63)

All feedback from @ilblackdragon, @gemini-code-assist[bot], and self-review has been addressed:

Correctness

  • error_text truncation — now takes last 500 chars (where tracebacks are), not first
  • duration_ms: 0 — threads real Instant::now() timing through the event
  • VmPanic never emitted — all 9 catch_unwind Err(_) paths now return Ok(CodeExecutionResult { failure: Some(VmPanic) }) instead of propagating EngineError::Effect
  • Classifier too loose — tightened "syntax" → "syntaxerror", removed "os" && "denied" substring match, reordered checks (specific before generic)
  • had_error + failure_category semantic drift — replaced with single failure: Option<CodeExecutionFailure> field. GatePause variant removed (gate pauses are suspensions, not failures)
  • Serialize vs Display mismatch — added #[serde(rename_all = "snake_case")]

Stability

  • DefaultHasher — replaced with inline FNV-1a (stable across Rust versions)
  • Forward-compat — added #[serde(other)] Unknown variant to EventKind

Tests

  • Added orchestrator caller test verifying CodeExecutionFailed event emission
  • Added classifier edge case tests (syntax-word-not-syntaxerror, VmPanic serialization)

Style

  • to_ascii_lowercase() per convention
  • Added use CodeExecutionFailure import, removed verbose paths
  • Expanded fallback comment acknowledging mixed-era thread limitation
  • Fixed misleading CodeExecutionFailed docstring

All 395 tests pass, cargo fmt clean.

@serrrfirat

Copy link
Copy Markdown
Collaborator Author

@ilblackdragon — addressed all your feedback in 88d3f63. Here's a walkthrough of the design decisions:


1. had_error + failure_category → sum type

Replaced both fields with a single failure: Option<CodeExecutionFailure>. This makes invalid states unrepresentable — every failure has a category, and failure.is_some() replaces had_error. The JSON key remains "had_error" in the orchestrator result dict for Python compat (default.py:659).

Removed the GatePause variant entirely. Gate pauses aren't failures — they're suspensions represented by need_approval: Some(ThreadOutcome::GatePaused { .. }). The old code set failure_category: Some(GatePause) with had_error: false, which was dead data (the CodeExecutionFailed event was gated on had_error). Clean separation now: failure tracks errors, need_approval tracks suspensions.

2. VmPanic emission

All 9 catch_unwind Err(_) paths now return Ok(CodeExecutionResult { failure: Some(VmPanic), ... }) instead of Err(EngineError::Effect). Previously these panics:

  • Propagated as EngineError → thread failure, no instrumentation
  • Made VmPanic a dead variant

Now they:

  • Flow through the orchestrator's event emission path → proper CodeExecutionFailed event
  • Include stdout captured up to the panic point (valuable for diagnosing Monty bugs)
  • Show up in trace analysis with IssueSeverity::Error

This is safe because each Err(_) path is a return — the Monty VM handle is consumed and dropped, no corrupt state leaks.

3. Error text truncation (first → last)

Both ActionFailed and CodeExecutionFailed now take the last 500 chars of stdout. Python tracebacks appear at the end, after any print() output. This matches what compact_output_metadata() already does for the LLM context.

4. Classifier tightening

Reordered checks from most specific to most generic:

  1. ResourceLimit (multi-keyword: "timed out", "timeout", "memory limit", "fuel", etc.)
  2. OsDenied (only "os operations are not permitted" and "oserror" — removed the loose contains("os") && contains("denied") that matched "compose", "diagnosis", etc.)
  3. SyntaxError ("syntaxerror" instead of "syntax" — the actual Python exception type)
  4. RuntimeError (catch-all)

5. Stable hash

FNV-1a (64-bit) inline — 6 lines, no new dependency, deterministic across Rust versions forever. DefaultHasher is explicitly documented as unstable across versions.

6. Serde consistency

#[serde(rename_all = "snake_case")] on CodeExecutionFailure — wire format now matches Display output. Since this type is new in this PR, no backward compat concern.

#[serde(other)] Unknown on EventKind — older binaries reading events from newer binaries will deserialize unknown variants as Unknown instead of failing. Unit variant per serde's externally-tagged enum requirement.

7. Real timing

Instant::now() wraps the execute_code() call in the orchestrator. duration_ms is now the actual wall-clock time of code execution, not a placeholder zero.

Not addressed (noted as follow-up)

  • Integration test against real Monty runtime for each failure category — worth doing but requires pinning specific Monty error formats, better as a separate PR
  • e2e test for aggregated failure counts — analytics consumer doesn't exist yet

…e tautological assert

- Replace `!result.failure.is_some()` with `result.failure.is_none()` in scripting tests
- Remove tautological `duration_ms >= 0` assertion on unsigned type in orchestrator test
- Add missing V24 migration checksum to checksums.lock

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added scope: db/postgres PostgreSQL backend DB MIGRATION PR adds or modifies PostgreSQL or libSQL migration definitions labels Apr 15, 2026
@ilblackdragon

ilblackdragon commented Apr 15, 2026 •

Copy link
Copy Markdown
Member

Review

Overview

Introduces CodeExecutionFailure and threads it through CodeExecutionResult (replacing had_error: bool). Adds a CodeExecutionFailed structured event emitted from orchestrator::handle_execute_code_step, upgrades trace.rs to consume it with a message-scraping fallback, and adds EventKind::Unknown for forward-compat deserialization.

Strengths

  • Clean shape: category travels with the result; orchestrator emits both ActionFailed and the new CodeExecutionFailed so existing observers aren't broken.
  • #[serde(other)] Unknown on EventKind is a nice rolling-deploy hedge.
  • classify_runtime_error is well-tested — specific vs. substring false positives are covered (e.g. "unexpected syntax" is not SyntaxError).
  • code_hash uses FNV-1a rather than DefaultHasher — correct call for cross-version stability.
  • Tests exercise the caller (execute_code_step_emits_code_execution_failed_event), not just the helper.
  • trace.rs severity escalation (VmPanic/ResourceLimit → Error) is sensible.

Correctness concerns

1. Behavior change on VM panic (scripting.rs, 7 sites). Previously catch_unwind failures returned Err(EngineError::Effect { … }); now they return Ok(CodeExecutionResult { failure: Some(VmPanic), … }). This is a semantic shift — any caller that matched on Err to escalate/abort will now silently continue. The orchestrator path handles this correctly via result.failure, but please audit other call sites of execute_code / execute_code_with_skills.

2. PR description mismatch — GatePause variant missing. The description lists 8 variants including GatePause, but CodeExecutionFailure has only 7 (no GatePause). Gate-paused execution sets failure: None, which is consistent, so the description is just wrong. Please update.

3. Unrelated migration churn. migrations/checksums.lock adds V24__llm_calls_created_at_index but no V24 migration file is in this PR. Almost certainly a stray artifact from rebase/merge — drop it or it'll produce a phantom checksum mismatch against whatever branch actually adds that migration.

4. Fallback branch is all-or-nothing. Per the comment in trace.rs: a thread with any CodeExecutionFailed event skips message-scraping entirely, so errors from pre-instrumentation steps in a mixed-era thread go unreported. Consider scraping messages that predate the earliest CodeExecutionFailed event, or accept a brief dup window. At minimum call this out as a known limitation in the PR body.

Nits / quality

  • The "last 500 chars of stdout" logic is duplicated in orchestrator.rs — extract a small tail_chars(&str, usize) helper (called twice back-to-back in the same function).
  • classify_runtime_error: lower.contains("fuel") is broad; a runtime error mentioning the word "fuel" would be miscategorized as ResourceLimit. Tightening to "out of fuel" / "fuel exhausted" removes the risk at zero cost to the tests.
  • CodeExecutionFailure::Display + Serialize(rename_all = "snake_case") duplicate the snake-case mapping in two places. A single source (e.g. as_str() used by both) avoids drift if a variant is added — you even added a test specifically to guard this.
  • EventKind::Unknown has no doc on expected consumer behavior (probably: log + skip). Worth a one-liner.
  • Typo in test message: "syntax error should set had_error" — the field is now failure.

Security / perf

No new I/O or allocations in a hot path. chars().count() + chars().skip() walks stdout twice on error; acceptable for a failure path capped at ~500-char output. error is truncated in the event; code_hash is non-cryptographic but fine for correlation.

Summary

Solid, well-tested instrumentation. Three things to resolve before merge:

  1. Drop the checksums.lock change (or explain it).
  2. Audit callers affected by the Err → Ok(failure=VmPanic) shift.
  3. Fix the PR description (no GatePause variant exists).

…ighten fuel match, clippy (#2483)

- Extract `tail_chars(s, n)` helper to deduplicate last-N-chars logic in
  `handle_execute_code_step` (used by both ActionFailed and
  CodeExecutionFailed event emission)
- Tighten `classify_runtime_error` fuel match from `contains("fuel")` to
  `contains("out of fuel") || contains("fuel exhausted")` to avoid
  miscategorizing runtime errors that mention the word "fuel"
- Fix test message typo: "should set had_error" → "should set failure"
- Fix clippy warnings: `!x.is_some()` → `x.is_none()`, remove
  tautological `u64 >= 0` assertion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Addressed review feedback from #2483 (comment):

Code fixes (d5baf70):

  1. Extract tail_chars helper — deduplicated the last-500-chars logic in handle_execute_code_step (was copy-pasted for both ActionFailed and CodeExecutionFailed events)
  2. Tighten fuel match — contains("fuel") → contains("out of fuel") || contains("fuel exhausted") to avoid miscategorizing runtime errors mentioning "fuel"
  3. Fix test typo — "should set had_error" → "should set failure"
  4. Fix clippy warnings — !x.is_some() → x.is_none(), removed tautological u64 >= 0 assertion

PR description updates:

  • Fixed variant count (7, not 8 — no GatePause variant)
  • Added caller audit section confirming only orchestrator.rs calls execute_code in production
  • Documented mixed-era fallback as a known limitation
  • checksums.lock was already dropped in the previous fix commit

Rebase artifact — no V24 migration exists in this PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Apr 15, 2026
Rewrite the code-review skill from a 6-bullet checklist into a
paranoid-architect workflow that handles both local diffs and GitHub
PRs end-to-end:

- Two input shapes: local `git diff` or `owner/repo N` /
  `github.com/.../pull/N` URLs.
- Step 1 wraps GitHub fetches in `async def` + `FINAL(await ...)` to
  avoid the closure-capture quirk that kept tripping LLMs (see the
  paired codeact preamble update); reads metadata, diff, and files
  via three sequential awaits instead of `asyncio.gather`.
- Step 2 reads each changed file in full (raw media type, no base64
  module needed) so reviews account for surrounding context.
- Step 3 runs the change through six lenses: correctness, edge cases,
  security (with a real adversarial checklist), test coverage, docs,
  architecture.
- Step 4 renders findings as a severity table and asks which to post.
- Step 5 posts line-level comments via the PR comments endpoint with
  the captured head SHA, falling back to issue comments for
  multi-file findings.

Bumps `requires.skills` to include `github` so the activation pulls
in the GitHub API recipes via the chain-loader.

Adds a live e2e test (`e2e_live_code_review.rs`) plus a recorded
trace fixture (PR #2483) so the workflow is replayable without
hitting GitHub.

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Code execution failure categorization

No verified findings in the current diff. The change threads structured code-execution failure categories from the scripting runtime into the orchestrator caller and trace analyzer, and it keeps the fallback message-based detection for older threads that predate the new event.

@serrrfirat
serrrfirat merged commit 017218d into staging Apr 16, 2026
15 checks passed
@serrrfirat
serrrfirat deleted the claude/audit-v2-engine-usage-I7dIz branch April 16, 2026 12:18
@claude

claude Bot commented Apr 16, 2026

Copy link
Copy Markdown

Code review

Found 5 issues:

  1. [HIGH:95] Overly broad error classification in classify_runtime_error — The function checks lower.contains("oserror") which will misclassify legitimate Python OSErrors (FileNotFoundError, PermissionError, etc.) as OsDenied. This field should only represent Monty sandbox rejections, not generic OSError exceptions. Check for the exact message "OS operations are not permitted" only.

https://github.com/anthropics/ironclaw/blob/017218d0a342ba6bda4c9b9f755db3c352a0e65b/crates/ironclaw_engine/src/executor/scripting.rs#L515-L535

  1. [HIGH:90] Double character iteration in tail_chars — The function calls .chars().count() then .chars().skip(), iterating the entire string twice. For large stdout (common in code execution), this is O(2n). Function is called twice per failure (lines 756, 784), compounding the cost.

https://github.com/anthropics/ironclaw/blob/017218d0a342ba6bda4c9b9f755db3c352a0e65b/crates/ironclaw_engine/src/executor/orchestrator.rs#L2196-L2204

Consider iterating once: collect into a Vec, or use byte-based slicing if ASCII.

  1. [HIGH:85] Unbounded string allocation in classification hot path — classify_runtime_error() calls .to_ascii_lowercase() on potentially multi-KB error messages, creating a full copy. Called 4+ times per failure (lines 367, 506, 544, 761). Use case-insensitive matching directly or only on prefixes.

https://github.com/anthropics/ironclaw/blob/017218d0a342ba6bda4c9b9f755db3c352a0e65b/crates/ironclaw_engine/src/executor/scripting.rs#L513-L535

  1. [MEDIUM:90] DRY violation: tail_chars computed twice per failure — Lines 756 and 784 both call tail_chars(&result.stdout, 500), producing the same 500-char tail for both error message and event. Extract to a variable to avoid redundant iteration.

https://github.com/anthropics/ironclaw/blob/017218d0a342ba6bda4c9b9f755db3c352a0e65b/crates/ironclaw_engine/src/executor/orchestrator.rs#L749-L800

  1. [MEDIUM:75] Event cloning on broadcast includes large String fields — At lines 777, 796, ThreadEvent is cloned when broadcasting, including the full error String field (up to 500 chars per failure). Consider Arc for cheaper cloning if event volume becomes significant.

https://github.com/anthropics/ironclaw/blob/017218d0a342ba6bda4c9b9f755db3c352a0e65b/crates/ironclaw_engine/src/executor/orchestrator.rs#L777-L800


Notes:

  • No security vulnerabilities found. Panic handling, truncation, and error classification are sound.
  • Tests follow CLAUDE.md "test through the caller" pattern correctly.
  • Forward compatibility (#[serde(other)] Unknown) is well done.

serrrfirat pushed a commit that referenced this pull request Apr 16, 2026
Rewrite the code-review skill from a 6-bullet checklist into a
paranoid-architect workflow that handles both local diffs and GitHub
PRs end-to-end:

- Two input shapes: local `git diff` or `owner/repo N` /
  `github.com/.../pull/N` URLs.
- Step 1 wraps GitHub fetches in `async def` + `FINAL(await ...)` to
  avoid the closure-capture quirk that kept tripping LLMs (see the
  paired codeact preamble update); reads metadata, diff, and files
  via three sequential awaits instead of `asyncio.gather`.
- Step 2 reads each changed file in full (raw media type, no base64
  module needed) so reviews account for surrounding context.
- Step 3 runs the change through six lenses: correctness, edge cases,
  security (with a real adversarial checklist), test coverage, docs,
  architecture.
- Step 4 renders findings as a severity table and asks which to post.
- Step 5 posts line-level comments via the PR comments endpoint with
  the captured head SHA, falling back to issue comments for
  multi-file findings.

Bumps `requires.skills` to include `github` so the activation pulls
in the GitHub API recipes via the chain-loader.

Adds a live e2e test (`e2e_live_code_review.rs`) plus a recorded
trace fixture (PR #2483) so the workflow is replayable without
hitting GitHub.
ilblackdragon added a commit that referenced this pull request Apr 17, 2026
…ates (#2528)

* feat(skills): paranoid-architect code-review skill v2

Rewrite the code-review skill from a 6-bullet checklist into a
paranoid-architect workflow that handles both local diffs and GitHub
PRs end-to-end:

- Two input shapes: local `git diff` or `owner/repo N` /
  `github.com/.../pull/N` URLs.
- Step 1 wraps GitHub fetches in `async def` + `FINAL(await ...)` to
  avoid the closure-capture quirk that kept tripping LLMs (see the
  paired codeact preamble update); reads metadata, diff, and files
  via three sequential awaits instead of `asyncio.gather`.
- Step 2 reads each changed file in full (raw media type, no base64
  module needed) so reviews account for surrounding context.
- Step 3 runs the change through six lenses: correctness, edge cases,
  security (with a real adversarial checklist), test coverage, docs,
  architecture.
- Step 4 renders findings as a severity table and asks which to post.
- Step 5 posts line-level comments via the PR comments endpoint with
  the captured head SHA, falling back to issue comments for
  multi-file findings.

Bumps `requires.skills` to include `github` so the activation pulls
in the GitHub API recipes via the chain-loader.

Adds a live e2e test (`e2e_live_code_review.rs`) plus a recorded
trace fixture (PR #2483) so the workflow is replayable without
hitting GitHub.

* docs(github): clarify search endpoints, response envelope, @me queries

LLMs kept inventing a `search_issues` action and looping over
`/repos/{owner}/{repo}/pulls` for "my PRs" queries. Clarify the
GitHub tool surface in three places:

- `tools-src/github/src/lib.rs` and `registry/tools/github.json`:
  enumerate the three real search actions and call out that
  `search_issues_pull_requests` covers both. Add the canonical
  `is:pr author:@me sort:updated-desc` recipe for cross-repo "my PRs".

- `skills/github/SKILL.md`: add an "Authenticated User & Cross-Repo
  Queries" section with copy-paste recipes for `@me`, the search
  endpoints with proper URL encoding, and the response-envelope
  contract (`body` is parsed JSON for application/json, raw `str` for
  diff endpoints — never call `json.loads()` on it, never write
  `.get("body", body)` as a fallback).

* fix: resolve CI failures — clippy useless_conversion + missing test harness methods

- Remove `.into_iter()` on `details` in catalog.rs (clippy::useless_conversion)
- Add `with_skills_dir` to `LiveTestHarnessBuilder` for e2e_live_code_review test
- Add `active_skill_names` to `TestRig` extracting from SkillActivated status events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(skills): address zmanian + gemini review — URL encoding, multi-line comments, description trimming (#2528)

- URL-encode file paths in GitHub API content URLs
- Add start_line/start_side to multi-line comment example
- Add 'locally' keyword override for mode detection
- Trim overly long schema descriptions
- Remove duplicated /search/issues note from Common Mistakes
- Fetch PR title from trace fixture instead of hard-coding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(test): propagate skills_dir into TestRig config (#2528)

LiveTestHarnessBuilder::with_skills_dir() stored a PathBuf but only
used it as an is_some() flag — the actual SkillRegistry always pointed
at an empty temp directory. Now the stored path flows through
TestRigBuilder::with_skills_dir() into config.skills.local_dir and
the SkillRegistry constructor.

Also generalizes the hardcoded nearai/ironclaw repo name in the
github skill's response-handling example to {owner}/{repo}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@henrypark133 henrypark133 mentioned this pull request Apr 21, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…on (nearai#2483)

* feat(engine): add code execution failure categorization instrumentation

Add structured error classification to the v2 engine's CodeAct execution
path so we can measure whether REPL failures come from Monty VM
limitations, LLM logic errors, tool dispatch issues, or resource limits.

- Add CodeExecutionFailure enum (8 categories: SyntaxError, RuntimeError,
  NameLookup, VmPanic, ResourceLimit, ToolError, GatePause, OsDenied)
- Add CodeExecutionFailed event kind to EventKind for event sourcing
- Tag every error return path in scripting.rs with the correct category
- Emit CodeExecutionFailed events from the orchestrator on code errors
- Enhance trace analyzer to use structured events (with fallback to
  message-level pattern matching for pre-instrumentation threads)
- Expand fallback error patterns from 4 to 10 Python exception types
- Add 13 regression tests covering classification and trace detection

This enables aggregate queries like "what % of code failures are Monty
VM panics vs LLM generating bad Python" to inform runtime decisions.

https://claude.ai/code/session_018jFKVTjv1pkzwJobw43HwP

* fix(engine): address ilblackdragon + gemini review — failure instrumentation correctness (nearai#2483)

- Replace `had_error: bool` + `failure_category: Option<_>` with single
  `failure: Option<CodeExecutionFailure>` field, making invalid states
  unrepresentable
- Remove `GatePause` variant (gate pauses are suspensions, not failures)
- Convert all 9 catch_unwind Err(_) paths to emit VmPanic instead of
  propagating EngineError, so panics get proper instrumentation events
- Fix error_text truncation: take last 500 chars (where tracebacks are),
  not first 500 chars (where print output is)
- Thread real `Instant::now()` timing through `duration_ms` instead of
  hardcoded 0
- Tighten `classify_runtime_error`: "syntax" → "syntaxerror", remove
  loose `"os" && "denied"` substring match, reorder checks
- Replace `DefaultHasher` with FNV-1a for stable cross-version hashing
- Add `#[serde(rename_all = "snake_case")]` so Serialize matches Display
- Add `#[serde(other)] Unknown` to EventKind for forward-compat
- Fix misleading CodeExecutionFailed docstring
- Add orchestrator caller test for CodeExecutionFailed event emission
- Use `to_ascii_lowercase()` per project convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve clippy warnings — simplify boolean expressions and remove tautological assert

- Replace `!result.failure.is_some()` with `result.failure.is_none()` in scripting tests
- Remove tautological `duration_ms >= 0` assertion on unsigned type in orchestrator test
- Add missing V24 migration checksum to checksums.lock

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): address ilblackdragon review nits — tail_chars helper, tighten fuel match, clippy (nearai#2483)

- Extract `tail_chars(s, n)` helper to deduplicate last-N-chars logic in
  `handle_execute_code_step` (used by both ActionFailed and
  CodeExecutionFailed event emission)
- Tighten `classify_runtime_error` fuel match from `contains("fuel")` to
  `contains("out of fuel") || contains("fuel exhausted")` to avoid
  miscategorizing runtime errors that mention the word "fuel"
- Fix test message typo: "should set had_error" → "should set failure"
- Fix clippy warnings: `!x.is_some()` → `x.is_none()`, remove
  tautological `u64 >= 0` assertion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): drop stray V24 checksums.lock entry (nearai#2483)

Rebase artifact — no V24 migration exists in this PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…ates (nearai#2528)

* feat(skills): paranoid-architect code-review skill v2

Rewrite the code-review skill from a 6-bullet checklist into a
paranoid-architect workflow that handles both local diffs and GitHub
PRs end-to-end:

- Two input shapes: local `git diff` or `owner/repo N` /
  `github.com/.../pull/N` URLs.
- Step 1 wraps GitHub fetches in `async def` + `FINAL(await ...)` to
  avoid the closure-capture quirk that kept tripping LLMs (see the
  paired codeact preamble update); reads metadata, diff, and files
  via three sequential awaits instead of `asyncio.gather`.
- Step 2 reads each changed file in full (raw media type, no base64
  module needed) so reviews account for surrounding context.
- Step 3 runs the change through six lenses: correctness, edge cases,
  security (with a real adversarial checklist), test coverage, docs,
  architecture.
- Step 4 renders findings as a severity table and asks which to post.
- Step 5 posts line-level comments via the PR comments endpoint with
  the captured head SHA, falling back to issue comments for
  multi-file findings.

Bumps `requires.skills` to include `github` so the activation pulls
in the GitHub API recipes via the chain-loader.

Adds a live e2e test (`e2e_live_code_review.rs`) plus a recorded
trace fixture (PR nearai#2483) so the workflow is replayable without
hitting GitHub.

* docs(github): clarify search endpoints, response envelope, @me queries

LLMs kept inventing a `search_issues` action and looping over
`/repos/{owner}/{repo}/pulls` for "my PRs" queries. Clarify the
GitHub tool surface in three places:

- `tools-src/github/src/lib.rs` and `registry/tools/github.json`:
  enumerate the three real search actions and call out that
  `search_issues_pull_requests` covers both. Add the canonical
  `is:pr author:@me sort:updated-desc` recipe for cross-repo "my PRs".

- `skills/github/SKILL.md`: add an "Authenticated User & Cross-Repo
  Queries" section with copy-paste recipes for `@me`, the search
  endpoints with proper URL encoding, and the response-envelope
  contract (`body` is parsed JSON for application/json, raw `str` for
  diff endpoints — never call `json.loads()` on it, never write
  `.get("body", body)` as a fallback).

* fix: resolve CI failures — clippy useless_conversion + missing test harness methods

- Remove `.into_iter()` on `details` in catalog.rs (clippy::useless_conversion)
- Add `with_skills_dir` to `LiveTestHarnessBuilder` for e2e_live_code_review test
- Add `active_skill_names` to `TestRig` extracting from SkillActivated status events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(skills): address zmanian + gemini review — URL encoding, multi-line comments, description trimming (nearai#2528)

- URL-encode file paths in GitHub API content URLs
- Add start_line/start_side to multi-line comment example
- Add 'locally' keyword override for mode detection
- Trim overly long schema descriptions
- Remove duplicated /search/issues note from Common Mistakes
- Fetch PR title from trace fixture instead of hard-coding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(test): propagate skills_dir into TestRig config (nearai#2528)

LiveTestHarnessBuilder::with_skills_dir() stored a PathBuf but only
used it as an is_some() flag — the actual SkillRegistry always pointed
at an empty temp directory. Now the stored path flows through
TestRigBuilder::with_skills_dir() into config.skills.local_dir and
the SkillRegistry constructor.

Also generalizes the hardcoded nearai/ironclaw repo name in the
github skill's response-handling example to {owner}/{repo}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs DB MIGRATION PR adds or modifies PostgreSQL or libSQL migration definitions risk: low Changes to docs, tests, or low-risk modules scope: db/postgres PostgreSQL backend size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants