Skip to content

Detect prompt-cache breaks and stop doomed compaction loops - #5975

Merged
ilblackdragon merged 17 commits into
mainfrom
cache-first-loop
Jul 14, 2026
Merged

ilblackdragon merged 17 commits into
mainfrom
cache-first-loop

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

Stack 1/4 on #5959. Lessons from studying Claude Code's execution engine, applied to the measured KV-cache collapse (82% hit rate at ~120 calls → 29% at 425 calls on claw-swe-bench run 799636ce, a ~3.5× cost multiplier on long agentic turns).

Part 1 — prompt-cache break detector (ironclaw_runner::model_gateway)

Every completed model call now emits a per-call tracing::debug! cache-usage series (cache_read / cache_creation / input tokens), and a tracing::warn! "prompt cache break detected" when the turn-over-turn cache read drops >5% AND >10K tokens — with attribution hints: seconds since last call, whether the tool-definition list changed (name-list hash), whether the system prompt changed (content hash). Per-run scoping via a bounded activity log on both gateway flavors. This is the diagnostic that tells us why the hit rate collapses (compaction rewrite vs provider routing vs surface churn) instead of guessing from run totals.

Part 2 — compaction circuit breaker (ironclaw_agent_loop)

Claude Code added the same guard after measuring ~250K wasted API calls/day from doomed auto-compaction loops. If compaction runs 3 consecutive times without bringing the token estimate back under the trigger threshold, the circuit opens for the rest of the run (one warn, covers forced/byte-cap compaction too; old checkpoints rehydrate with the breaker closed). State lives in the typed CompactionStrategyState slot per the strategies contract; no default parameter values changed. Family fingerprints gained ineffective_trip_limit=3 and digests were regenerated red→green.

Testing

Red-then-green throughout: pure-function tests for the break predicate (boundary cases), activity-log scope isolation + attribution flags, slot transition tests, strategy skip tests, a through-the-caller PromptStage executor test, and a gateway-tier #[traced_test] asserting the warn fires within a run (and stays quiet at exact thresholds). cargo test -p ironclaw_agent_loop fully green in seconds (this stack's base also pauses tokio time in the scenario test targets, which the deep availability backoff from #5959 had slowed to ~423s). fmt + clippy clean.

Deferred (documented in code): message-prefix divergence attribution (prior-call messages aren't retained at the gateway), tool schema hashing (names+count only), eviction-on-run-completion (no lifecycle signal at this layer).

🤖 Generated with Claude Code

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@ironloopai

ironloopai Bot commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor

🔎 IronLoop Review Status

Head: deee8a2d58064ad6908d6dfeb67404936c4f380a
Result: 1 blocking finding across 1 reviewer.
Next: Address the blocking findings, push fixes, then re-run the relevant reviewer.
Updated: 2026-07-11T02:54:30.365Z

Current reviewers:

Reviewer State Verdict Findings Last update
ironloop/common-reviewer (reviewer) Completed Changes requested 1 blocking finding / 0 notes 2026-07-11T02:54:30.356Z
Reviewer summaries
Reviewer Detail
ironloop/common-reviewer (reviewer) Changes requested; 1 blocking finding; Found a blocking regression in the new compaction breaker: once it opens, explicit shrink/forced compactions are skipped too, which can make context-overflow recovery retry the sa…
Recent activity
Time Reviewer State Detail
2026-07-11T02:48:33.270Z ironloop/common-reviewer (reviewer) Queued Accepted review request for head deee8a2.
2026-07-11T02:48:33.270Z ironloop/common-reviewer (reviewer) Queued Waiting for this reviewer lane to become available.
2026-07-11T02:48:33.955Z ironloop/common-reviewer (reviewer) Started Reviewer worker started attempt 1.
2026-07-11T02:48:36.570Z ironloop/common-reviewer (reviewer) Workspace ready Prepared isolated checkout (merge_ref) at 0de2a87.
2026-07-11T02:54:21.585Z ironloop/common-reviewer (reviewer) Running Codex is reviewing; process live; elapsed 5m 47s; timeout in 14m 13s; last heartbeat 2026-07-11T02:54:21.585Z. Codex emitted stderr output at 2026-07-11T02:53:12.583Z.
2026-07-11T02:54:30.356Z ironloop/common-reviewer (reviewer) Result captured Changes requested; 1 blocking finding.
2026-07-11T02:54:30.356Z ironloop/common-reviewer (reviewer) Completed Review completed and terminal status was persisted.
Available commands
  • @ironloopai help
  • @ironloopai agents
  • @ironloopai review
  • @ironloopai review --agent <agent>
  • @ironloopai status
Run metadata

Admission: webhook accepted the request and IronLoop persisted reviewer state before this projection.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a9767f94-100a-40f8-b9e7-58c1ecd958b9

📥 Commits

Reviewing files that changed from the base of the PR and between 1b47e9b and 246798d.

📒 Files selected for processing (1)
  • crates/ironclaw_agent_loop/src/families/mod.rs
💤 Files with no reviewable changes (1)
  • crates/ironclaw_agent_loop/src/families/mod.rs

📝 Walkthrough

Summary by CodeRabbit

  • Improvements
    • Added a circuit breaker that stops automatic prompt context compaction after repeated ineffective compactions, while keeping forced/recovery compaction available.
    • Compaction effectiveness is now judged against the correct baseline for threshold-triggered vs forced/recovery compactions.
    • Added prompt-cache activity tracking to detect cache continuity breaks and attribute them to system prompt or tool-surface changes.
  • Tests
    • Expanded coverage for the compaction circuit breaker (trip/open behavior and reset rules), forced-compaction bypass, and prompt-cache break detection (including tool-surface changes and repair retry).

Walkthrough

Changes

Compaction now tracks trigger-specific effectiveness, opens a one-way circuit after repeated ineffective rebuilds, and preserves forced-compaction bypasses. Model gateways now record per-run prompt-cache usage and classify cache breaks using token and prompt-signature changes. Tests and path references were updated.

Compaction effectiveness

Layer / File(s) Summary
Compaction state and effectiveness model
crates/ironclaw_agent_loop/src/state/...
State tracks ineffective attempts, circuit status, and pending baselines, with unit coverage.
Baseline-aware compaction decisions
crates/ironclaw_agent_loop/src/strategies/..., crates/ironclaw_agent_loop/src/default_planner.rs
Strategies select trigger-specific baselines and suppress automatic triggers when the circuit is open.
Prompt rebuild accounting and integration
crates/ironclaw_agent_loop/src/executor/..., crates/ironclaw_agent_loop/src/families/...
Effectiveness is evaluated after prompt rebuilds, with updated fingerprints and PromptStage tests.

Prompt-cache telemetry

Layer / File(s) Summary
Cache activity tracking model
crates/ironclaw_runner/src/model_gateway/prompt_cache_activity.rs
Per-run cache state, signatures, break attribution, eviction, and unit tests are added.
Gateway cache recording
crates/ironclaw_runner/src/model_gateway.rs
Both gateway variants pass run-scoped cache handles through model requests.
Cache-break integration coverage
crates/ironclaw_runner/tests/llm_gateway.rs
Scripted cache reads and log assertions cover continuity, retries, and tool-surface attribution.

Path contract updates

Layer / File(s) Summary
Updated path references
crates/ironclaw_architecture/tests/..., crates/ironclaw_threads/tests/...
Fixtures and contract comments reference current module paths.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

Compaction rebuild accounting

sequenceDiagram
  participant PromptPlanningPipeline
  participant PromptCompactionStep
  participant CompactionStrategyState
  PromptPlanningPipeline->>PromptCompactionStep: run compaction decision
  PromptCompactionStep->>CompactionStrategyState: store pending baseline
  PromptPlanningPipeline->>PromptPlanningPipeline: rebuild prompt bundle
  PromptPlanningPipeline->>CompactionStrategyState: observe rebuilt token estimate
  CompactionStrategyState-->>PromptPlanningPipeline: update circuit state
Loading

Prompt-cache recording

sequenceDiagram
  participant ModelGateway
  participant CompleteModelRequest
  participant PromptCacheCallScope
  participant PromptCacheActivityLog
  ModelGateway->>PromptCacheCallScope: create run scope
  ModelGateway->>CompleteModelRequest: pass cache scope
  CompleteModelRequest->>PromptCacheCallScope: record response usage
  PromptCacheCallScope->>PromptCacheActivityLog: classify cache continuity or break
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: think-in-universe

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the core changes and testing, but it omits most required template sections like Change Type, Linked Issue, Security Impact, and Rollback Plan. Fill out the repository template sections, especially Change Type, Linked Issue, Validation checklist, Security Impact, Blast Radius, Rollback Plan, and Review Follow-Through.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: prompt-cache break detection and stopping compaction loops.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Jul 11, 2026

@ironloopai ironloopai 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.

❌ IronLoop Review: reviewer

Review at a glance

Verdict Blocking Notes Inline Head
❌ Changes requested 1 0 1 deee8a2d5806

Head: deee8a2d58064ad6908d6dfeb67404936c4f380a
Next: Fix the blocking findings, push the PR branch, then re-run this reviewer.

Run details

Status: Current
Needs human: no
Needs validation: no

Summary

Found a blocking regression in the new compaction breaker: once it opens, explicit shrink/forced compactions are skipped too, which can make context-overflow recovery retry the same oversized prompt until abort.

Findings

Blocking: 1 / Notes: 0

Blocking findings

1. ❌ [MEDIUM] Do not let the circuit breaker suppress forced shrink compactions

Location: crates/ironclaw_agent_loop/src/strategies/compaction.rs:52-54
The new early return runs before force_compact_on_next_iteration, so once compaction_circuit_open is true all explicit compaction requests become no-ops. That flag is how RetryAlteration::ShrinkContext handles provider ContextOverflow, and it is also used by byte-cap overflow handling. After the breaker trips, a context-overflow retry can enter the prompt stage, skip compaction here, rebuild the same oversized prompt, and burn through the model retry budget until the run aborts. Please limit the breaker to automatic compaction decisions, or make forced/recovery compaction fail fast through a distinct path instead of silently retrying unchanged.

Developer follow-up

After fixing this feedback:

  1. Push the fix to this PR branch.
  2. Re-run this reviewer with @ironloopai review --agent reviewer if you only changed this reviewer's findings.
  3. Re-run all reviewers with @ironloopai review when the fix may affect multiple areas.
  4. Use @ironloopai status to check queued/running/completed/failed/superseded state while reviewers run.

pub const DEFAULT_DEADLINE_MS: u64 = 30_000;

pub(super) fn can_evaluate(&self, state: &LoopExecutionState) -> bool {
if state.compaction_state.compaction_circuit_open {

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.

This check also blocks force_compact_on_next_iteration. That flag is how RetryAlteration::ShrinkContext handles context-overflow recovery, so after the breaker opens a provider context overflow can retry with the same oversized prompt until the retry budget aborts. Please let explicit forced/recovery compactions bypass the auto-compaction breaker, or fail fast through a distinct recovery path.

@serrrfirat serrrfirat 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.

Multi-agent + thermo-nuclear review

Disposition: Request changes

Five specialist reviewers, an intent analyzer, and an independent thermo-nuclear structural pass reviewed stack PR #5975 against base #5959. After confidence filtering and overlap deduplication, 9 findings remain.

  • Security: 0
  • Bugs: 1
  • Performance/concurrency: 1
  • Tests: 4 raw, 3 standalone (one overlaps the core breaker bug)
  • Conventions/contracts: 4
  • Thermo-nuclear: 1 structural finding, deduplicated with conventions
  • Reviewer failures: 0

The two blocking correctness findings show that the circuit breaker can miss both summary-driven and forced/byte-cap compact-recompact loops. The structural pass also rejects adding another cohesive subsystem directly to the 3.3K-line gateway.

// Code measured ~250K wasted API calls/day from exactly this
// compact-recompact doom loop before adding a breaker.
let circuit_was_open = state.compaction_state.compaction_circuit_open;
state.compaction_state = state

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.

[High · Performance/Correctness · 96%] Circuit breaker measures before the summary is added

The breaker classifies compaction immediately after retain_after_sequence, when the estimate contains only the retained tail and explicitly excludes the newly injected summary. A compactor can return a summary large enough that the rebuilt prompt stays above threshold, yet every attempt is recorded as effective because the tail alone is below threshold. The next iteration compacts again and resets the counter, so the doomed loop can continue through the 1,024-iteration backstop.

This was also flagged by the Tests lens: the caller test seeds the counter at limit - 1 and never proves three real prompt-stage compactions trip the breaker.

Fix: Measure after rebuilding the bundle including the summary, or carry a pending observation into the next iteration and compare the refreshed total before incrementing/resetting. Add a caller test driving three actual ineffective compactions and suppressing the fourth.

drop_through_seq,
preserve_tail_tokens: self.preserve_tail_tokens,
deadline_ms: self.deadline_ms,
trigger_threshold_tokens: self.prompt_context_budget.visible_transcript_tokens(),

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.

[High · Bug · 95%] Forced compactions use the wrong effectiveness threshold

Every trigger carries the normal visible-transcript threshold, including force_compact_on_next_iteration raised by ByteCapStrategy. A forced compaction can be completely ineffective while the retained prompt remains below that unrelated threshold; repeated ~32K capability-result overflows under the default ~108K transcript threshold will reset the counter rather than advance it. This contradicts the stated requirement that the breaker cover forced/byte-cap compactions.

Fix: Carry trigger-specific effectiveness data. For forced compactions compare against pre-compaction size or a byte-cap-specific target; keep the transcript threshold only for threshold-triggered compaction.

return Err(map_provider_error(error));
}
};
if let Some(scope) = cache_scope.as_ref() {

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.

[Medium · Tests · 90%] Tool-capable cache telemetry path is untested

The gateway-level cache-break test exercises only the text CompletionResponse branch. No integration test drives stream_model_with_capabilities through this distinct ToolCompletionResponse branch, so tool-definition hashing, recording, and attribution can regress unnoticed.

Fix: Add tests::llm_gateway::gateway_warns_and_attributes_prompt_cache_break_on_tool_capable_calls, using two same-run capability calls with a cache collapse and changed tool surface.

return Err(map_provider_error(error));
}
};
if let Some(scope) = cache_scope.as_ref() {

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.

[Medium · Tests · 85%] Repair retry cache recording has no test

A repairable tool-output failure performs a second provider call and records it as a separate cache observation, but existing repair tests do not assert this telemetry path.

Fix: Add tests::llm_gateway::gateway_records_prompt_cache_usage_for_tool_repair_retry, asserting both calls share the run scope and the retry emits a break on a large cache-read drop.

usage.cache_read_input_tokens,
) =>
{
PromptCacheObservation::Break {

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.

[Medium · Tests · 80%] System-prompt attribution branch is untested

Tests cover changed tools with an unchanged system prompt, but not the complementary branch. system_prompt_changed could remain permanently false without detection.

Fix: Add tests::model_gateway::prompt_cache_activity_log_attributes_system_prompt_only_break with unchanged tools and a changed system prompt.

system_prompt_changed,
} = observation
{
tracing::warn!(

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.

[Medium · Conventions · 100%] Internal cache diagnostics must not log at warn

This is internal prompt-cache diagnostic attribution, but tracing::warn! appears in and corrupts the REPL/TUI. The repository logging rule directs internal trace analysis to debug! (CLAUDE.md:84).

Fix: Emit at debug!, or use a diagnostics-only target excluded from interactive output.

trigger_threshold_tokens,
);
if !circuit_was_open && state.compaction_state.compaction_circuit_open {
tracing::warn!(

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.

[Medium · Conventions · 100%] Compaction breaker diagnostics must not log at warn

Opening an internal circuit breaker emits tracing::warn!, violating the same repository rule that internal engine diagnostics belong at debug!; the structured progress path already carries lifecycle information.

Fix: Change this diagnostic to debug!, or route it through a diagnostics-only target.

}
}

/// Relative drop factor for cache-break detection: the current call must read

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.

[Medium · Thermo/Conventions · 90%] Extract cache telemetry from the 3.3K-line gateway

This adds roughly 350 lines of cache-break policy, state, hashing, eviction, logging, and tests directly to model_gateway.rs, growing it from 2,948 to 3,296 lines. Prompt-cache activity is a cohesive new runtime-adapter concern, and crates/ironclaw_runner/AGENTS.md explicitly says to add a new file for a new concern. This makes an existing god-file materially broader.

Fix: Move the tracker, observation types, thresholds, signatures, eviction, logging, and unit tests into a private prompt_cache_activity submodule; leave only scope construction and recording calls in the gateway.

/// detects each break as it happens and attributes it to cheap request-shape
/// signals (tool surface or system prompt changed).
#[derive(Debug, Default)]
pub struct PromptCacheActivityLog {

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.

[Medium · Conventions · 95%] Keep the cache activity log private

PromptCacheActivityLog is pub, but every use and all methods are internal to model_gateway.rs; related state and observation types are private. This expands the downstream API without providing a usable external contract, and the PR gives no rationale for exporting it.

Fix: Remove pub, or use pub(crate) only if the extracted internal module requires crate-level visibility.

ilblackdragon and others added 9 commits July 12, 2026 19:11
… loop

Two failure modes discovered via claw-swe-bench-lite run 9ca133e5 (30% vs
hermes 65% on the same model) discarded hours of agent work:

- A transient provider 5xx storm aborted the whole run after 2 quick
  retries (max_attempts_per_class=2, backoff capped at 5s). Availability-
  class model errors (transient/unavailable/internal) now retry on their
  own deeper budget: max_model_availability_attempts=12 with a 1s..60s
  exponential backoff, riding out ~7 minutes of sustained provider
  failure. MAX_MODEL_RETRIES raised 8 -> 16 to let the strategy govern.

- DefaultBudgetStrategy's iteration_limit=32 failed closed mid-task with
  no synthesis (llm_calls in failed bench tasks clustered at exactly
  63/64/127/128). The default is now DEFAULT_ITERATION_BACKSTOP=1024
  (subagent 16 -> 256), documented as a runaway backstop: operational
  bounds are the resource budget system and stop-condition strategy.

New seam mirroring IRONCLAW_REBORN_PLANNED_DEFAULT_ITERATION_LIMIT:
IRONCLAW_REBORN_MODEL_AVAILABILITY_RETRY_ATTEMPTS ->
DefaultPlannedRuntimeConfig.planned_model_availability_retry_attempts ->
families::default_with_overrides. The integration group harness pins
attempts=1 so scenarios that deliberately script provider failures
(failure_category_demasked) reach Failed in seconds, not minutes; the
availability-retry tests run under paused tokio time.

Family fingerprint digests regenerated for the new strategy parameters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Benchmark traces showed the model retrying identical failing calls blind:
builtin.shell parameter errors and coding-tool path rejections reached it
as a bare category ("the tool input could not be encoded") because the
handlers built FirstPartyCapabilityError/CodingCapabilityError with no
safe_summary — the model-visible Diagnostic detail channel downstream was
already wired but starved (one agent burned 13 apply_patch calls against
an out-of-scope /testbed path with empty errors).

- shell.rs: shell_error/process_error now carry the concrete reason
  ("missing 'command' parameter", timeout duration, spawn failure),
  bounded to 512 chars. The strict safe-summary validator still falls
  back to the fixed category string; the reason always survives on the
  secret-scrubbed diagnostic channel.
- coding/paths.rs: scoped-path rejections name the offending path and
  the available scoped roots; permission rejections say the operation is
  not permitted on that mount.

Covered at the dispatch tier (coding state dispatch, host-runtime
invoke_capability) per test-through-the-caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The deep availability-retry backoff added for provider-outage ride-out
made outage-scripting scenario tests sleep for real: safety_nets alone
took ~423s (the exact cumulative backoff schedule) because scripted or
script-exhausted model errors now retry for minutes. Pause the clock on
all executor scenario targets — they drive the in-process mock host
exclusively, so timers auto-advance and the suites return to seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bility retries

The placeholder unconfigured provider's RequestFailed was mapped through the
catch-all Unavailable arm, so the new deep availability retry budget rode a
permanent configuration fault through ~7 minutes of exponential backoff.
Users with no LLM configured waited minutes for an error that retrying can
never fix, and the Reborn CLI smoke tests that pin fast nonzero exits timed
out (the 4 failures on CI run 29136954176).

Map errors carrying the shared UNCONFIGURED_PROVIDER_ID to
CredentialUnavailable, which is unclassified in loop recovery and therefore
terminal on first sight; the Settings → Inference hint travels on the
scrubbed detail channel. The provider id moves to a shared constant in
ironclaw_llm so the composition placeholder and the runner mapping cannot
drift.

Regression tests: unconfigured_provider_error_maps_to_credential_unavailable_
not_availability and unconfigured_provider_detection_requires_the_placeholder_
provider_id in model_gateway.rs; the existing smoke tests
(*_exits_nonzero_when_runtime_does_not_produce_reply) pin the fast-fail at
the caller tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nfig

families::default_with_overrides swapped budget/recovery strategies but
reused the planner's static version digest, so an overridden composition
carried the pure-default replay identity — violating the component-identity
contract (family.rs: the digest identifies replay-relevant configuration).

- Turn the cfg(test) fingerprint const into a runtime
  default_family_fingerprint(iteration_limit, model_availability_attempts)
  builder; override-built families hash it with their resolved values at
  composition time (BLAKE3, same path as the pinned const). The pure-default
  composition keeps the static DEFAULT_FAMILY_DIGEST, and overrides spelling
  out the production defaults hash to that same digest.
- Collapse the two Option args into a FamilyOverrides struct and drop the
  now-dead (None, None) branch in the runner's registry factory.
- Tests: digest differs per override knob and is deterministic; explicit
  production defaults reproduce the static digest; an attempts=1 override
  reaches the composed recovery strategy (one retry then abort).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cancel

Two model-stage fixes from the PR 5959 review:

MAX_MODEL_RETRIES=16 silently capped any configured availability budget of
16+: the retry loop fell through to a generic ModelError exit with
FailedExitDetails::default() — no failure category, no diagnostic ref —
before the strategy could reach its own Abort. The executor now derives the
loop bound from the composed strategy via
RecoveryStrategy::max_total_model_attempts() (DefaultRecoveryStrategy
computes it from its per-class + availability budgets with margin), so
every accepted override reaches the strategy's abort boundary. The
contract-bug fall-through now carries the last observed model error's
category and diagnostic ref instead of empty details.

The availability backoff sleep (up to 60s per attempt) was not
cancellation-aware: a cancel request could wait out the full delay. The
sleep now selects over the host's cancellation_requested() future (same
pattern as the prompt-compaction and failure-explanation waits), and a
boundary cancel check right after the alteration turns the wake into a
checkpointed Cancelled exit without issuing another model call.

Tests (paused tokio time): an availability budget of 20 — past the old
executor cap — fails with the strategy's model_unavailable category and
diagnostic ref after exactly 21 model calls; cancellation requested during
the first 1s backoff exits Cancelled without riding out the sleep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…enforced

The doc claimed operational bounds come from the resource budget system,
but ResourceBudgetPolicy.max_model_calls and the wall-clock cap are defined
and not applied; until they are, this backstop and the stop-condition
strategy are the only live ceilings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ator

PR 5959's headline feature (model-visible tool-failure reasons) never
reached the model for path-bearing reasons: LoopSafeSummary rejects
path/payload delimiters and newlines, and dispatch_failure_message
silently degraded every such reason to the generic category sentence
before it could reach the diagnostic channel.

- production.rs (failure_from): a host-authored safe_summary that fails
  LoopSafeSummary validation is preserved as the new
  DispatchFailureDetail::Diagnostic instead of being dropped; the
  message keeps the fixed category sentence (host-authored, Invariant 2).
- capability_port.rs: maps the Diagnostic detail into the model-visible
  CapabilityFailureDetail::Diagnostic, scrubbing secret values and
  normalizing control characters the observation validator rejects (so
  one stray escape byte cannot drop the whole observation); newlines
  are preserved. The RetrySameCall arm now forwards structured detail
  too.
- coding/paths.rs: scoped-path rejection summaries render the path and
  available roots delimiter-free ("path testbed replacer.go",
  "available roots: workspace") so they pass the strict validator —
  FilesystemDenied surfaces as a Denied loop outcome whose only
  model-visible channel is the summary itself.
- shell.rs: bounded_failure_reason documents the (now real) diagnostic
  flow; truncation remains char-based (no byte-boundary panics).

Regression tests:
- coding/mod.rs: out-of-scope rejection summary passes LoopSafeSummary
  and names path + roots; new read-only-mount write denial test pins
  FilesystemDenied with an actionable validated reason.
- host_runtime caller tier (first_party_coding_tools.rs): out-of-scope
  read and read-only write driven through invoke_capability, asserting
  the loop-boundary message survives validation and names path/root.
- host_runtime caller tier (first_party_builtin_tools.rs): shell
  sensitive-path rejection reason rides the Diagnostic detail while the
  strict summary channel stays path-free.
- production.rs: failure_from preserves rejected summaries (path +
  newline) on the Diagnostic detail and leaves validator-safe summaries
  on the message alone.
- capability_port.rs: Diagnostic detail reaches the model scrubbed
  (path kept, newline kept, control char normalized, secret redacted);
  empty-after-normalization diagnostics are dropped.
- shell.rs: bounded_failure_reason boundary tests (exactly 512 kept,
  513 truncated with ellipsis, multibyte truncation on char
  boundaries) and producer pin that paths/newlines are not
  pre-sanitized away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reborn_parity_qa harness scripts deliberately-failing replay gateways
(exhausted steps, mismatched requests) whose errors are availability-class;
the production 12-attempt backoff budget rode them for minutes and
reborn_trace_error_path_parity timed out waiting for Failed. Pin
planned_model_availability_retry_attempts=1 in the harness runtime config,
mirroring the integration group harness's env pin. The parity suite passes
in ~1s again; the pinned test is the regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ilblackdragon

Copy link
Copy Markdown
Member Author

All nine findings addressed across three commits (branch restacked on the updated #5959):

Breaker vs forced compactions (ironloopai blocking) — fixed (fea8159a1). can_evaluate now honors force_compact_on_next_iteration before consulting the breaker, so ContextOverflow ShrinkContext recovery and byte-cap overflow always compact; the breaker only gates automatic threshold-triggered compaction. The two tests that pinned the suppressing behavior are replaced with forced-bypass pins, including an executor-tier test driving the prompt stage with the breaker open + force flag set.

Effectiveness measured before the summary lands (serrrfirat High·96%) — fixed (fea8159a1). The observation is now deferred: the trigger's baseline is stashed in a serialized pending_effectiveness_baseline slot and compared against the next refreshed prompt (which includes the injected summary) before the counter increments/resets. The old test that seeded consecutive_ineffective_compactions = LIMIT - 1 now drives three real ineffective compactions (retained tail below threshold, summary pushing the rebuild back over — the exact doom loop you described; the old code fails this test) and asserts the fourth is suppressed.

Forced compactions judged against the wrong threshold (serrrfirat High·95%) — fixed (fea8159a1). CompactionDecision::Trigger now carries a CompactionEffectivenessBaseline enum: TriggerThresholdTokens for automatic triggers, PreCompactionPromptTokens for forced ones — a forced compaction is effective iff the prompt actually shrank, so repeated sub-threshold byte-cap overflows advance the breaker instead of resetting it. Pinned at slots tier and executor tier.

Test gaps (N1–N3) — added (39e15ba8d): tool-capable cache recording through stream_model_with_capabilities (two same-run calls, 200K→50K collapse, changed tool surface, asserts tool_definitions_changed); repair-retry cache assertions folded into the existing gateway_repairs_oversized_provider_tool_arguments test; system_prompt_changed: true attribution case added to the classification test.

Log levels (N4/N5) — downgraded to debug! per the repo logging rule (39e15ba8d, fea8159a1); the integration test is renamed gateway_records_prompt_cache_break_within_a_run and now fails if the record line carries WARN/ERROR. The cache-break signal still reaches operators via the structured observation records.

Structure (N6/N7) — extracted (b67a6712c): ~330 lines of telemetry (tracker, observation types, thresholds, signatures, eviction, unit tests) moved to a private model_gateway/prompt_cache_activity.rs submodule, everything pub(super) (tighter than pub(crate)); the gateway keeps only scope construction and record calls.

Three more real-time test paths rode the new 12-attempt availability
backoff for minutes (composition-core / reborn-core CI buckets):

- The substrate-only StubGateway returned Unavailable for a missing LLM
  gateway — a build-configuration fault retrying can never fix. It now
  returns CredentialUnavailable (unclassified in recovery, terminal),
  matching the placeholder-provider fail-fast.
- send_user_message_preserves_model_unavailable_after_retry_budget
  scripts a deliberate outage; RebornRuntimeInput gains a test-support
  with_model_availability_retry_attempts override (wins over the env
  knob) and the test pins attempts=2, keeping its retry-then-fail pin.
- turn_runner_worker_full_reborn_fails_cleanly_when_model_provider_is_offline
  now builds its registry via build_loop_family_registry_with_overrides
  with attempts=2 for the same reason.

The three tests pass in ~3s each again; they are the regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ilblackdragon and others added 5 commits July 12, 2026 22:41
The stub gateway now reports a configuration fault instead of an
availability blip; the two runtime.rs pins (and one comment) tracking
its failure category move from model_unavailable to
model_credentials_unavailable accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Benchmark analysis showed the provider KV cache hit rate collapsing
from ~82% to 29% as reborn agent runs grow past ~200 model calls,
roughly 3.5x input cost. Two defenses:

ironclaw_runner model gateway: track per-run cache_read continuity
across completed provider calls. Every call emits a debug-level
per-call cache series (cache_read / cache_creation / input tokens);
a drop of BOTH >5% relative and >10K tokens absolute against the
previous call in the same run warns "prompt cache break detected"
with cheap attribution hints: tool-definition list hash changed,
system prompt hash changed, seconds since last call. The pure
threshold decision (is_prompt_cache_break) is unit-tested at its
edges; per-run state lives in a bounded Mutex<HashMap> keyed by
TurnRunId on each gateway.

ironclaw_agent_loop compaction circuit breaker: when 3 consecutive
compactions complete without the post-compaction prompt estimate
dropping below the trigger threshold (compaction fired and didn't
help), a one-way breaker in the CompactionStrategyState slot opens
and compaction stays disabled for the remainder of the run, with a
single tracing::warn!. Claude Code measured ~250K wasted API
calls/day from exactly this compact-recompact doom loop before
adding a breaker. State follows the typed-slot pattern (strategy
reads, executor swaps); CompactionDecision::Trigger now carries the
trigger threshold so the executor can score effectiveness. All
compaction defaults (128000/20000/8000/...) are unchanged; the
family fingerprints gain ineffective_trip_limit=3 and their BLAKE3
digests are regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings N6/N7 on #5975: the ~350 lines of prompt-cache
continuity tracking (thresholds, break detection, observation types,
per-run scope eviction, logging) lived inline in the 3,300-line
model_gateway.rs and PromptCacheActivityLog was needlessly pub.

Pure move into model_gateway/prompt_cache_activity.rs (unit tests
included); the gateway now only constructs PromptCacheCallScope and
records completed calls. All moved types are pub(super) at most, so
PromptCacheActivityLog is no longer part of the crate's public API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rding

Review findings N1-N4 on #5975:

- N4: the cache-break record logged at warn!, but repo logging rules
  reserve info!/warn! for user-facing REPL output — internal
  diagnostics must use debug!. Downgraded, and renamed the gateway test
  to gateway_records_prompt_cache_break_within_a_run with a logs_assert
  that pins the record to a non-warn level.
- N1: added a gateway integration test driving
  stream_model_with_capabilities with two same-run tool-capable calls,
  a 200K->50K cached-read collapse, and a changed advertised tool
  surface — pinning ModelCallCacheUsage::from_tool_response recording
  and tool-surface break attribution on the tool-capable path.
- N2: extended gateway_repairs_oversized_provider_tool_arguments_
  before_registration with scripted cache usage so the repair-retry
  recording seam is covered: both calls must record, and the collapse
  across the retry is a break with an unchanged request shape.
- N3: extended the classification unit test with a changed-system-
  prompt break so system_prompt_changed=true attribution is pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eness, trigger-kind baselines

Fixes the three blocking review findings on #5975 (B1/B2/B3):

- B1: an open breaker suppressed forced/recovery compactions.
  force_compact_on_next_iteration is how ContextOverflow recovery
  (RetryAlteration::ShrinkContext) and byte-cap overflow request a
  shrink; with the breaker open they silently no-oped, the same
  oversized prompt was rebuilt, and the retry budget burned to abort.
  DefaultCompactionStrategy::can_evaluate now checks the force flag
  before the breaker — the breaker only gates automatic
  threshold-triggered compaction.

- B2: effectiveness was measured right after retain_after_sequence,
  before the injected summary landed in the prompt, so a huge summary
  that kept the prompt oversized still counted every compaction as
  effective and the doom loop persisted to the iteration backstop.
  PromptCompactionStep now stashes the baseline on the new
  compaction_state.pending_effectiveness_baseline slot; the executor
  consumes it via observe_pending_compaction_effectiveness once the
  prompt bundle is next rebuilt (immediately after the in-pipeline
  rebuild, or on the next iteration's candidate build for
  compaction-only SkipModel turns), so the comparison sees the
  summary's tokens.

- B3: forced compactions were judged against the unrelated
  visible-transcript threshold, letting a completely ineffective forced
  compaction reset the counter. CompactionDecision::Trigger now carries
  a trigger-kind-specific CompactionEffectivenessBaseline:
  TriggerThresholdTokens for automatic triggers,
  PreCompactionPromptTokens (did the prompt actually shrink?) for
  forced ones.

Also downgrades the breaker-opened log from warn! to debug! (N5):
internal loop diagnostics must not render into the REPL/TUI.

Tests (red-first where the old behavior was pinned):
- prompt_stage_circuit_breaker_disables_compaction_after_repeated_
  ineffective_runs now drives three REAL ineffective compactions
  through the prompt stage (no seeded counter) with a retained tail
  below the threshold so only the summary-aware measurement trips the
  breaker, and asserts the fourth threshold overflow is suppressed.
- prompt_stage_forced_compaction_bypasses_open_circuit_breaker pins
  B1+B3 at the executor tier: forced compaction runs with the breaker
  open and an effective shrink (260 -> 140) resets the counter even
  though 140 is still over the 90-token threshold.
- Strategy tests updated: forced-bypass triggers for both
  DefaultCompactionStrategy and ActiveTaskPreservingCompactionStrategy,
  threshold-gated skip kept, slots-level forced-baseline coverage, and
  checkpoint back-compat for the new pending slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ilblackdragon

Copy link
Copy Markdown
Member Author

@serrrfirat CI green across the stack; all 9 findings addressed (detailed response above) — please re-review.

Base automatically changed from reborn-loop-resilience to main July 14, 2026 02:43
Resolves conflicts from the #5959 squash and later main changes:
- executor/model.rs, production.rs, shell.rs, coding/paths.rs: take main's
  refined error mapping (failure summary with detail, PolicyDenied kind,
  debug-logged causes, bounded diagnostics + regression test)
- families/mod.rs + subagent.rs: keep this branch's compaction
  ineffective_trip_limit=3 fingerprint and matching digests; adopt main's
  FamilyOverrides builder setters
- composition runtime.rs/runtime_input.rs: take main's lazy env resolution
  and the ironclaw_loop_host rename (also fixed two stale
  ironclaw_loop_support mentions this branch carried); dedupe the
  with_model_availability_retry_attempts hook both sides added

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Coverage ratchet

Ratchet mode: ENFORCING

RATCHET PASS: global
  observed: 85.55% (302249 / 353301 lines)
  floor:    85.3% (tolerance 0.5pp -> effective floor 84.8%)
  denominator: 353301 lines now vs 320188 at floor capture (+33113 lines, +10.34%) — material change (>5%)

⚠️ 2 Reborn crate(s) have 0 int-tier coverage (target: 0) — ironclaw_prompt_envelope, ironclaw_scripts

Reborn integration-tier coverage

Line coverage (Reborn crates): 85.55% — 302249 / 353301 lines

Per-crate breakdown (63 crates, lowest-covered first)
Crate Line % Covered / Total
ironclaw_prompt_envelope 0% 0 / 88
ironclaw_scripts 0% 0 / 345
ironclaw_runtime_policy 31.75% 80 / 252
ironclaw_event_projections 43.31% 673 / 1554
ironclaw_run_state 53.07% 225 / 424
ironclaw_authorization 53.89% 464 / 861
ironclaw_triggers 59.89% 1792 / 2992
ironclaw_observability 61.54% 16 / 26
ironclaw_webui_v2 62.93% 2679 / 4257
ironclaw_mcp 63.03% 578 / 917
ironclaw_reborn_cli 66.18% 4488 / 6781
ironclaw_filesystem 67.1% 3833 / 5712
ironclaw_dispatcher 67.15% 92 / 137
ironclaw_memory 69.2% 773 / 1117
ironclaw_reborn_migration 71.57% 1551 / 2167
ironclaw_trust 72.88% 661 / 907
ironclaw_capabilities 74.39% 1685 / 2265
ironclaw_wasm_limiter 74.6% 47 / 63
ironclaw_reborn_event_store 74.67% 958 / 1283
ironclaw_extractors 74.72% 538 / 720
ironclaw_llm 78.36% 20328 / 25941
ironclaw_product_context 78.57% 11 / 14
ironclaw_first_party_extensions 78.81% 5576 / 7075
ironclaw_process_sandbox 80.65% 671 / 832
ironclaw_wasm_product_adapters 80.71% 1448 / 1794
ironclaw_memory_native 81.22% 3205 / 3946
ironclaw_secrets 82.7% 2791 / 3375
ironclaw_events 82.86% 1765 / 2130
ironclaw_reborn_identity 83.59% 433 / 518
ironclaw_wasm 83.97% 1011 / 1204
ironclaw_auth 83.99% 3147 / 3747
ironclaw_reborn_config 84.06% 1814 / 2158
ironclaw_processes 84.44% 993 / 1176
ironclaw_common 84.85% 1490 / 1756
ironclaw_turns 84.88% 13624 / 16050
ironclaw_host_api 85.13% 2663 / 3128
ironclaw_product_workflow 85.56% 10837 / 12666
ironclaw_projects 85.92% 659 / 767
ironclaw_network 86.12% 670 / 778
ironclaw_threads 86.7% 4594 / 5299
ironclaw_slack_v2_adapter 86.79% 1806 / 2081
ironclaw_product_adapters 86.98% 3207 / 3687
ironclaw_skills 87.58% 4470 / 5104
ironclaw_hooks 87.75% 9917 / 11302
ironclaw_product_adapter_registry 88.06% 531 / 603
ironclaw_reborn_traces 88.19% 11946 / 13546
ironclaw_host_runtime 88.59% 17395 / 19635
ironclaw_reborn_composition 89.28% 80119 / 89741
ironclaw_extensions 89.38% 2971 / 3324
ironclaw_approvals 89.41% 1587 / 1775
ironclaw_runner 89.42% 16908 / 18908
ironclaw_reborn_openai_compat 89.49% 3771 / 4214
ironclaw_conversations 90.33% 3120 / 3454
ironclaw_event_streams 90.82% 1009 / 1111
ironclaw_loop_host 92.52% 14811 / 16008
ironclaw_resources 92.83% 4736 / 5102
ironclaw_attachments 93.06% 630 / 677
ironclaw_reborn_webui_ingress 93.19% 2217 / 2379
ironclaw_telegram_v2_adapter 93.62% 2511 / 2682
ironclaw_agent_loop 94.83% 9148 / 9647
ironclaw_safety 95.04% 3677 / 3869
ironclaw_first_party_extension_ports 95.24% 3343 / 3510
ironclaw_outbound 95.59% 3556 / 3720

This table itself is informational and never gates the PR on its own — not the percentage, not the per-crate holes, not the 0-coverage callout. A separate coverage ratchet (dry-run until enforce=true; see tests/integration/coverage-floor.toml) can fail the build on specific configured floors.

Exemptions (3 entry/entries excluded from the accounting above)
Module / Crate Reason Issue
crate: ironclaw_embeddings v1-only: consumed only by root ironclaw (src/app.rs, src/tools/builtin/memory.rs, src/workspace/mod.rs, src/config/{mod,embeddings}.rs); no crates/* dependents. Covered by "Tests (Legacy)". #5657
crate: ironclaw_gateway v1-only: consumed only by root ironclaw (src/channels/web/platform/static_files.rs, src/channels/web/handlers/frontend.rs); no crates/* dependents. Covered by "Tests (Legacy)". #5657
crate: ironclaw_tui v1-only: consumed only by root ironclaw (src/main.rs, src/channels/tui.rs); no crates/* dependents. Crate's own doc comment confirms it bridges INTO v1, not Reborn. Covered by "Tests (Legacy)". #5657

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/ironclaw_runner/src/model_gateway.rs (1)

1108-1127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make cache_scope required here
All eight call sites in crates/ironclaw_runner/src/model_gateway.rs pass Some(self.prompt_cache_scope(run_id)), so cache_scope can be a plain PromptCacheCallScope and the if let Some(scope) branches can be removed. This matches the repo rule against optional runtime dependencies when wiring always supplies them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_runner/src/model_gateway.rs` around lines 1108 - 1127, The
complete_model_request function should require cache_scope as a plain
PromptCacheCallScope instead of Option<PromptCacheCallScope>. Update all eight
call sites to pass self.prompt_cache_scope(run_id) directly, then remove the
if-let Some(scope) branching and use cache_scope directly wherever it is
consumed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_runner/src/model_gateway.rs`:
- Around line 711-714: Add tests covering the RoutedLlmProviderModelGateway
request paths that pass prompt_cache_activity and prompt_cache_scope, including
the run_id forwarded to prompt_cache_scope. Mirror the existing
LlmProviderModelGateway cache assertions while exercising the routed gateway
methods around complete_model_request, and verify the expected cache activity
and scope are recorded.

In `@crates/ironclaw_runner/src/model_gateway/prompt_cache_activity.rs`:
- Around line 101-153: Add a regression test for
PromptCacheActivityLog::observe_model_call that records
PROMPT_CACHE_MAX_TRACKED_SCOPES + 1 distinct run IDs, then calls the oldest run
ID again and asserts it returns PromptCacheObservation::FirstCall. Ensure the
test distinguishes the eviction case from continuity for retained scopes.

---

Outside diff comments:
In `@crates/ironclaw_runner/src/model_gateway.rs`:
- Around line 1108-1127: The complete_model_request function should require
cache_scope as a plain PromptCacheCallScope instead of
Option<PromptCacheCallScope>. Update all eight call sites to pass
self.prompt_cache_scope(run_id) directly, then remove the if-let Some(scope)
branching and use cache_scope directly wherever it is consumed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 96dc0bcb-1122-4b27-b133-580417a7ead0

📥 Commits

Reviewing files that changed from the base of the PR and between 8330644 and 1b47e9b.

📒 Files selected for processing (14)
  • crates/ironclaw_agent_loop/src/default_planner.rs
  • crates/ironclaw_agent_loop/src/executor/prompt.rs
  • crates/ironclaw_agent_loop/src/executor/tests.rs
  • crates/ironclaw_agent_loop/src/families/mod.rs
  • crates/ironclaw_agent_loop/src/families/subagent.rs
  • crates/ironclaw_agent_loop/src/state.rs
  • crates/ironclaw_agent_loop/src/state/slots.rs
  • crates/ironclaw_agent_loop/src/strategies/active_task_compaction.rs
  • crates/ironclaw_agent_loop/src/strategies/compaction.rs
  • crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs
  • crates/ironclaw_runner/src/model_gateway.rs
  • crates/ironclaw_runner/src/model_gateway/prompt_cache_activity.rs
  • crates/ironclaw_runner/tests/llm_gateway.rs
  • crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs

Comment on lines +711 to 714
Some(self.prompt_cache_scope(run_id)),
)
.await
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

RoutedLlmProviderModelGateway cache wiring is untested.

The routed gateway gets the identical prompt_cache_activity/prompt_cache_scope wiring as LlmProviderModelGateway, but llm_gateway.rs's new cache tests only exercise LlmProviderModelGateway. The underlying complete_model_request recording logic is covered through that sibling, but a wiring regression specific to the routed variant (e.g., wrong run_id threading) wouldn't be caught.

Also applies to: 750-753, 793-796, 837-840

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_runner/src/model_gateway.rs` around lines 711 - 714, Add
tests covering the RoutedLlmProviderModelGateway request paths that pass
prompt_cache_activity and prompt_cache_scope, including the run_id forwarded to
prompt_cache_scope. Mirror the existing LlmProviderModelGateway cache assertions
while exercising the routed gateway methods around complete_model_request, and
verify the expected cache activity and scope are recorded.

Comment on lines +101 to +153
impl PromptCacheActivityLog {
/// Classifies the call against the previous call in the same run scope
/// and stores it as the new last-call state.
fn observe_model_call(
&self,
run_id: TurnRunId,
usage: ModelCallCacheUsage,
tool_definitions_hash: u64,
system_prompt_hash: u64,
) -> PromptCacheObservation {
let mut scopes = self
.scopes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let observation = match scopes.get(&run_id) {
None => PromptCacheObservation::FirstCall,
Some(previous)
if is_prompt_cache_break(
previous.cache_read_input_tokens,
usage.cache_read_input_tokens,
) =>
{
PromptCacheObservation::Break {
previous_cache_read_tokens: previous.cache_read_input_tokens,
tool_definitions_changed: previous.tool_definitions_hash
!= tool_definitions_hash,
system_prompt_changed: previous.system_prompt_hash != system_prompt_hash,
}
}
Some(_) => PromptCacheObservation::Continuity,
};
let seconds_since_last_call = scopes
.get(&run_id)
.map(|previous| previous.observed_at.elapsed().as_secs_f64());
if scopes.len() >= PROMPT_CACHE_MAX_TRACKED_SCOPES && !scopes.contains_key(&run_id) {
let oldest = scopes
.iter()
.min_by_key(|(_, state)| state.observed_at)
.map(|(scope_run_id, _)| *scope_run_id);
if let Some(oldest) = oldest {
scopes.remove(&oldest);
}
}
scopes.insert(
run_id,
LastCallCacheState {
cache_read_input_tokens: usage.cache_read_input_tokens,
tool_definitions_hash,
system_prompt_hash,
observed_at: Instant::now(),
},
);
drop(scopes);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Eviction bound (PROMPT_CACHE_MAX_TRACKED_SCOPES) has no regression test.

observe_model_call correctly caps scopes by evicting the oldest entry before insert, but no test in this file (or the gateway tests) drives more than a couple of run ids through it. This is the one branch in the function not covered by the otherwise-thorough test suite below.

As per coding guidelines, "Every new feature and bug fix must begin with a test that demonstrates the expected behavior." Add a test that inserts PROMPT_CACHE_MAX_TRACKED_SCOPES + 1 distinct run ids and asserts the oldest scope was evicted (e.g., a subsequent call for the evicted run id classifies as FirstCall again).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_runner/src/model_gateway/prompt_cache_activity.rs` around
lines 101 - 153, Add a regression test for
PromptCacheActivityLog::observe_model_call that records
PROMPT_CACHE_MAX_TRACKED_SCOPES + 1 distinct run IDs, then calls the oldest run
ID again and asserts it returns PromptCacheObservation::FirstCall. Ensure the
test distinguishes the eviction case from continuity for retained scopes.

Source: Coding guidelines

serrrfirat
serrrfirat previously approved these changes Jul 14, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ilblackdragon
ilblackdragon merged commit 36d5495 into main Jul 14, 2026
65 checks passed
@ilblackdragon
ilblackdragon deleted the cache-first-loop branch July 14, 2026 06:15
ilblackdragon added a commit that referenced this pull request Jul 15, 2026
…ion (#5977)

* Ride out provider outages and drop the 32-call turn cap in the reborn loop

Two failure modes discovered via claw-swe-bench-lite run 9ca133e5 (30% vs
hermes 65% on the same model) discarded hours of agent work:

- A transient provider 5xx storm aborted the whole run after 2 quick
  retries (max_attempts_per_class=2, backoff capped at 5s). Availability-
  class model errors (transient/unavailable/internal) now retry on their
  own deeper budget: max_model_availability_attempts=12 with a 1s..60s
  exponential backoff, riding out ~7 minutes of sustained provider
  failure. MAX_MODEL_RETRIES raised 8 -> 16 to let the strategy govern.

- DefaultBudgetStrategy's iteration_limit=32 failed closed mid-task with
  no synthesis (llm_calls in failed bench tasks clustered at exactly
  63/64/127/128). The default is now DEFAULT_ITERATION_BACKSTOP=1024
  (subagent 16 -> 256), documented as a runaway backstop: operational
  bounds are the resource budget system and stop-condition strategy.

New seam mirroring IRONCLAW_REBORN_PLANNED_DEFAULT_ITERATION_LIMIT:
IRONCLAW_REBORN_MODEL_AVAILABILITY_RETRY_ATTEMPTS ->
DefaultPlannedRuntimeConfig.planned_model_availability_retry_attempts ->
families::default_with_overrides. The integration group harness pins
attempts=1 so scenarios that deliberately script provider failures
(failure_category_demasked) reach Failed in seconds, not minutes; the
availability-retry tests run under paused tokio time.

Family fingerprint digests regenerated for the new strategy parameters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Surface tool-failure reasons to the model for shell and coding tools

Benchmark traces showed the model retrying identical failing calls blind:
builtin.shell parameter errors and coding-tool path rejections reached it
as a bare category ("the tool input could not be encoded") because the
handlers built FirstPartyCapabilityError/CodingCapabilityError with no
safe_summary — the model-visible Diagnostic detail channel downstream was
already wired but starved (one agent burned 13 apply_patch calls against
an out-of-scope /testbed path with empty errors).

- shell.rs: shell_error/process_error now carry the concrete reason
  ("missing 'command' parameter", timeout duration, spawn failure),
  bounded to 512 chars. The strict safe-summary validator still falls
  back to the fixed category string; the reason always survives on the
  secret-scrubbed diagnostic channel.
- coding/paths.rs: scoped-path rejections name the offending path and
  the available scoped roots; permission rejections say the operation is
  not permitted on that mount.

Covered at the dispatch tier (coding state dispatch, host-runtime
invoke_capability) per test-through-the-caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Run agent_loop scenario test targets under paused tokio time

The deep availability-retry backoff added for provider-outage ride-out
made outage-scripting scenario tests sleep for real: safety_nets alone
took ~423s (the exact cumulative backoff schedule) because scripted or
script-exhausted model errors now retry for minutes. Pause the clock on
all executor scenario targets — they drive the in-process mock host
exclusively, so timers auto-advance and the suites return to seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fail fast when no LLM provider is configured instead of riding availability retries

The placeholder unconfigured provider's RequestFailed was mapped through the
catch-all Unavailable arm, so the new deep availability retry budget rode a
permanent configuration fault through ~7 minutes of exponential backoff.
Users with no LLM configured waited minutes for an error that retrying can
never fix, and the Reborn CLI smoke tests that pin fast nonzero exits timed
out (the 4 failures on CI run 29136954176).

Map errors carrying the shared UNCONFIGURED_PROVIDER_ID to
CredentialUnavailable, which is unclassified in loop recovery and therefore
terminal on first sight; the Settings → Inference hint travels on the
scrubbed detail channel. The provider id moves to a shared constant in
ironclaw_llm so the composition placeholder and the runner mapping cannot
drift.

Regression tests: unconfigured_provider_error_maps_to_credential_unavailable_
not_availability and unconfigured_provider_detection_requires_the_placeholder_
provider_id in model_gateway.rs; the existing smoke tests
(*_exits_nonzero_when_runtime_does_not_produce_reply) pin the fast-fail at
the caller tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Derive override-built default-family replay identity from resolved config

families::default_with_overrides swapped budget/recovery strategies but
reused the planner's static version digest, so an overridden composition
carried the pure-default replay identity — violating the component-identity
contract (family.rs: the digest identifies replay-relevant configuration).

- Turn the cfg(test) fingerprint const into a runtime
  default_family_fingerprint(iteration_limit, model_availability_attempts)
  builder; override-built families hash it with their resolved values at
  composition time (BLAKE3, same path as the pinned const). The pure-default
  composition keeps the static DEFAULT_FAMILY_DIGEST, and overrides spelling
  out the production defaults hash to that same digest.
- Collapse the two Option args into a FamilyOverrides struct and drop the
  now-dead (None, None) branch in the runner's registry factory.
- Tests: digest differs per override knob and is deterministic; explicit
  production defaults reproduce the static digest; an attempts=1 override
  reaches the composed recovery strategy (one retry then abort).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Let the recovery strategy own the model retry guard; wake backoff on cancel

Two model-stage fixes from the PR 5959 review:

MAX_MODEL_RETRIES=16 silently capped any configured availability budget of
16+: the retry loop fell through to a generic ModelError exit with
FailedExitDetails::default() — no failure category, no diagnostic ref —
before the strategy could reach its own Abort. The executor now derives the
loop bound from the composed strategy via
RecoveryStrategy::max_total_model_attempts() (DefaultRecoveryStrategy
computes it from its per-class + availability budgets with margin), so
every accepted override reaches the strategy's abort boundary. The
contract-bug fall-through now carries the last observed model error's
category and diagnostic ref instead of empty details.

The availability backoff sleep (up to 60s per attempt) was not
cancellation-aware: a cancel request could wait out the full delay. The
sleep now selects over the host's cancellation_requested() future (same
pattern as the prompt-compaction and failure-explanation waits), and a
boundary cancel check right after the alteration turns the wake into a
checkpointed Cancelled exit without issuing another model call.

Tests (paused tokio time): an availability budget of 20 — past the old
executor cap — fails with the strategy's model_unavailable category and
diagnostic ref after exactly 21 model calls; cancellation requested during
the first 1s backoff exits Cancelled without riding out the sleep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Clarify DEFAULT_ITERATION_BACKSTOP doc: resource budgets are not yet enforced

The doc claimed operational bounds come from the resource budget system,
but ResourceBudgetPolicy.max_model_calls and the wall-clock cap are defined
and not applied; until they are, this backstop and the stop-condition
strategy are the only live ceilings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Carry tool-failure reasons to the model past the strict summary validator

PR 5959's headline feature (model-visible tool-failure reasons) never
reached the model for path-bearing reasons: LoopSafeSummary rejects
path/payload delimiters and newlines, and dispatch_failure_message
silently degraded every such reason to the generic category sentence
before it could reach the diagnostic channel.

- production.rs (failure_from): a host-authored safe_summary that fails
  LoopSafeSummary validation is preserved as the new
  DispatchFailureDetail::Diagnostic instead of being dropped; the
  message keeps the fixed category sentence (host-authored, Invariant 2).
- capability_port.rs: maps the Diagnostic detail into the model-visible
  CapabilityFailureDetail::Diagnostic, scrubbing secret values and
  normalizing control characters the observation validator rejects (so
  one stray escape byte cannot drop the whole observation); newlines
  are preserved. The RetrySameCall arm now forwards structured detail
  too.
- coding/paths.rs: scoped-path rejection summaries render the path and
  available roots delimiter-free ("path testbed replacer.go",
  "available roots: workspace") so they pass the strict validator —
  FilesystemDenied surfaces as a Denied loop outcome whose only
  model-visible channel is the summary itself.
- shell.rs: bounded_failure_reason documents the (now real) diagnostic
  flow; truncation remains char-based (no byte-boundary panics).

Regression tests:
- coding/mod.rs: out-of-scope rejection summary passes LoopSafeSummary
  and names path + roots; new read-only-mount write denial test pins
  FilesystemDenied with an actionable validated reason.
- host_runtime caller tier (first_party_coding_tools.rs): out-of-scope
  read and read-only write driven through invoke_capability, asserting
  the loop-boundary message survives validation and names path/root.
- host_runtime caller tier (first_party_builtin_tools.rs): shell
  sensitive-path rejection reason rides the Diagnostic detail while the
  strict summary channel stays path-free.
- production.rs: failure_from preserves rejected summaries (path +
  newline) on the Diagnostic detail and leaves validator-safe summaries
  on the message alone.
- capability_port.rs: Diagnostic detail reaches the model scrubbed
  (path kept, newline kept, control char normalized, secret redacted);
  empty-after-normalization diagnostics are dropped.
- shell.rs: bounded_failure_reason boundary tests (exactly 512 kept,
  513 truncated with ellipsis, multibyte truncation on char
  boundaries) and producer pin that paths/newlines are not
  pre-sanitized away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pin availability retries to 1 in the parity-QA binary E2E harness

The reborn_parity_qa harness scripts deliberately-failing replay gateways
(exhausted steps, mismatched requests) whose errors are availability-class;
the production 12-attempt backoff budget rode them for minutes and
reborn_trace_error_path_parity timed out waiting for Failed. Pin
planned_model_availability_retry_attempts=1 in the harness runtime config,
mirroring the integration group harness's env pin. The parity suite passes
in ~1s again; the pinned test is the regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fail fast on the stub gateway and pin retries in scripted-outage tests

Three more real-time test paths rode the new 12-attempt availability
backoff for minutes (composition-core / reborn-core CI buckets):

- The substrate-only StubGateway returned Unavailable for a missing LLM
  gateway — a build-configuration fault retrying can never fix. It now
  returns CredentialUnavailable (unclassified in recovery, terminal),
  matching the placeholder-provider fail-fast.
- send_user_message_preserves_model_unavailable_after_retry_budget
  scripts a deliberate outage; RebornRuntimeInput gains a test-support
  with_model_availability_retry_attempts override (wins over the env
  knob) and the test pins attempts=2, keeping its retry-then-fail pin.
- turn_runner_worker_full_reborn_fails_cleanly_when_model_provider_is_offline
  now builds its registry via build_loop_family_registry_with_overrides
  with attempts=2 for the same reason.

The three tests pass in ~3s each again; they are the regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update stub-gateway failure pins to model_credentials_unavailable

The stub gateway now reports a configuration fault instead of an
availability blip; the two runtime.rs pins (and one comment) tracking
its failure category move from model_unavailable to
model_credentials_unavailable accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Detect prompt-cache breaks and stop doomed compaction loops

Benchmark analysis showed the provider KV cache hit rate collapsing
from ~82% to 29% as reborn agent runs grow past ~200 model calls,
roughly 3.5x input cost. Two defenses:

ironclaw_runner model gateway: track per-run cache_read continuity
across completed provider calls. Every call emits a debug-level
per-call cache series (cache_read / cache_creation / input tokens);
a drop of BOTH >5% relative and >10K tokens absolute against the
previous call in the same run warns "prompt cache break detected"
with cheap attribution hints: tool-definition list hash changed,
system prompt hash changed, seconds since last call. The pure
threshold decision (is_prompt_cache_break) is unit-tested at its
edges; per-run state lives in a bounded Mutex<HashMap> keyed by
TurnRunId on each gateway.

ironclaw_agent_loop compaction circuit breaker: when 3 consecutive
compactions complete without the post-compaction prompt estimate
dropping below the trigger threshold (compaction fired and didn't
help), a one-way breaker in the CompactionStrategyState slot opens
and compaction stays disabled for the remainder of the run, with a
single tracing::warn!. Claude Code measured ~250K wasted API
calls/day from exactly this compact-recompact doom loop before
adding a breaker. State follows the typed-slot pattern (strategy
reads, executor swaps); CompactionDecision::Trigger now carries the
trigger threshold so the executor can score effectiveness. All
compaction defaults (128000/20000/8000/...) are unchanged; the
family fingerprints gain ineffective_trip_limit=3 and their BLAKE3
digests are regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extract prompt-cache telemetry into a private model_gateway submodule

Review findings N6/N7 on #5975: the ~350 lines of prompt-cache
continuity tracking (thresholds, break detection, observation types,
per-run scope eviction, logging) lived inline in the 3,300-line
model_gateway.rs and PromptCacheActivityLog was needlessly pub.

Pure move into model_gateway/prompt_cache_activity.rs (unit tests
included); the gateway now only constructs PromptCacheCallScope and
records completed calls. All moved types are pub(super) at most, so
PromptCacheActivityLog is no longer part of the crate's public API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Record prompt-cache breaks at debug level and cover tool-capable recording

Review findings N1-N4 on #5975:

- N4: the cache-break record logged at warn!, but repo logging rules
  reserve info!/warn! for user-facing REPL output — internal
  diagnostics must use debug!. Downgraded, and renamed the gateway test
  to gateway_records_prompt_cache_break_within_a_run with a logs_assert
  that pins the record to a non-warn level.
- N1: added a gateway integration test driving
  stream_model_with_capabilities with two same-run tool-capable calls,
  a 200K->50K cached-read collapse, and a changed advertised tool
  surface — pinning ModelCallCacheUsage::from_tool_response recording
  and tool-surface break attribution on the tool-capable path.
- N2: extended gateway_repairs_oversized_provider_tool_arguments_
  before_registration with scripted cache usage so the repair-retry
  recording seam is covered: both calls must record, and the collapse
  across the retry is a break with an unchanged request shape.
- N3: extended the classification unit test with a changed-system-
  prompt break so system_prompt_changed=true attribution is pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix compaction circuit breaker: forced bypass, summary-aware effectiveness, trigger-kind baselines

Fixes the three blocking review findings on #5975 (B1/B2/B3):

- B1: an open breaker suppressed forced/recovery compactions.
  force_compact_on_next_iteration is how ContextOverflow recovery
  (RetryAlteration::ShrinkContext) and byte-cap overflow request a
  shrink; with the breaker open they silently no-oped, the same
  oversized prompt was rebuilt, and the retry budget burned to abort.
  DefaultCompactionStrategy::can_evaluate now checks the force flag
  before the breaker — the breaker only gates automatic
  threshold-triggered compaction.

- B2: effectiveness was measured right after retain_after_sequence,
  before the injected summary landed in the prompt, so a huge summary
  that kept the prompt oversized still counted every compaction as
  effective and the doom loop persisted to the iteration backstop.
  PromptCompactionStep now stashes the baseline on the new
  compaction_state.pending_effectiveness_baseline slot; the executor
  consumes it via observe_pending_compaction_effectiveness once the
  prompt bundle is next rebuilt (immediately after the in-pipeline
  rebuild, or on the next iteration's candidate build for
  compaction-only SkipModel turns), so the comparison sees the
  summary's tokens.

- B3: forced compactions were judged against the unrelated
  visible-transcript threshold, letting a completely ineffective forced
  compaction reset the counter. CompactionDecision::Trigger now carries
  a trigger-kind-specific CompactionEffectivenessBaseline:
  TriggerThresholdTokens for automatic triggers,
  PreCompactionPromptTokens (did the prompt actually shrink?) for
  forced ones.

Also downgrades the breaker-opened log from warn! to debug! (N5):
internal loop diagnostics must not render into the REPL/TUI.

Tests (red-first where the old behavior was pinned):
- prompt_stage_circuit_breaker_disables_compaction_after_repeated_
  ineffective_runs now drives three REAL ineffective compactions
  through the prompt stage (no seeded counter) with a retained tail
  below the threshold so only the summary-aware measurement trips the
  breaker, and asserts the fourth threshold overflow is suppressed.
- prompt_stage_forced_compaction_bypasses_open_circuit_breaker pins
  B1+B3 at the executor tier: forced compaction runs with the breaker
  open and an effective shrink (260 -> 140) resets the counter even
  though 140 is still over the 90-token threshold.
- Strategy tests updated: forced-bypass triggers for both
  DefaultCompactionStrategy and ActiveTaskPreservingCompactionStrategy,
  threshold-gated skip kept, slots-level forced-baseline coverage, and
  checkpoint back-compat for the new pending slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Advertise Reborn skills as a one-line listing; load bodies on activation

Reborn injected the FULL SKILL.md body of every keyword-scored skill
into the prompt on every run — ~7K tokens of mostly irrelevant
instructions per turn (measured in claw-swe-bench traces) — and the
skill section churned whenever scores shifted, defeating prompt-cache
stability. Flip to the Claude Code model: advertise skills as one
`- name: description` line each and load a body only when the model
invokes it via builtin.skill_activate.

- `SelectableSkillContextSource` gains `SkillInjectionMode`:
  * `Listing` (Reborn default, `IRONCLAW_REBORN_SKILL_INJECTION`
    overridable to `full`): non-activated skills collapse into ONE
    discoverable "available-skills" candidate — a fixed header pointing
    at builtin.skill_activate plus one line per skill (250-char
    description cap, 100-entry cap). Full bodies inject only for
    explicit $name mentions and model-selected activations. Keyword
    scoring still runs and RANKS the listing; criteria selections no
    longer consume the model's activation budget.
  * `Full` stays the library default so non-Reborn consumers and the
    skill-execution capture plan keep their existing semantics.
- Prompt build and by-ref model-message resolution rebuild the snippet
  set independently, so both now derive body-eligibility and listing
  ranking from the merged active plan — identical sets, stable
  msg:snippet refs.
- `SkillContextService` safe summaries are now the bounded first line
  of the description instead of the full text: the multi-skill listing
  (5.6KB) blew past MODEL_SAFE_SUMMARY_MAX_BYTES and killed runs at the
  prompt stage — a latent bug for any long skill description.

v1 (`src/`) skill behavior is untouched.

Tests: reborn_integration_skill_activate pins the one-liner listing
(body absent pre-activation, present post-activation) at the captured
model-request seam; new selector listing-mode tests in
ironclaw_first_party_extension_ports; safe-summary regression in
skill_context_service_contract; env parse + config propagation tests in
ironclaw_reborn_composition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Upgrade criteria-listed skills to ModelSelected on later skill_activate

merge_active_plan deduped merged activations by bundle id alone, so a
skill already in the run plan as a listing-only ActivationCriteria entry
silently dropped a later ModelSelected activation for the same bundle:
the mode never upgraded, body_eligible_bundle_ids never included it, and
the SKILL.md body never injected — while the skill_activate tool still
reported "activated 1 skill(s)" because the merged plan matched by name.

Mode-priority merge: when the incoming activation is body-eligible
(ModelSelected/ExplicitMention) and the existing entry for the same
bundle is ActivationCriteria, upgrade the existing entry's mode in
place; never downgrade. Regression test drives record_user_message
(criteria match) then activate_skills_for_run and asserts the body
injects on the next prompt build and leaves the listing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Hash full model content into skill snippet model-message refs

skill_snippet_model_message_ref hashed only [snippet_ref, safe_summary,
ordinal], and the safe summary is first-line-only (capped 256 chars).
The available-skills listing's first line is a constant header, so
adding, removing, or reordering listed skills changed the listing body
without changing the ref — a stale ref resolved against the new content
instead of failing closed.

Include the full model_content in the hashed fields. Refs are per-run
ephemeral (built at prompt time, resolved live by
instruction_snippet_messages_by_ref in the same run; never persisted —
the executor's message index stores only sequence/kind/token counts), so
the deliberate one-time hash rotation is safe; pinned ref literals in
the turns/loop-support contract tests are updated to the new layout.
Regression tests pin that listings differing only after the first line
produce different refs and identical content produces a stable ref.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Jul 17, 2026
#5978)

* Ride out provider outages and drop the 32-call turn cap in the reborn loop

Two failure modes discovered via claw-swe-bench-lite run 9ca133e5 (30% vs
hermes 65% on the same model) discarded hours of agent work:

- A transient provider 5xx storm aborted the whole run after 2 quick
  retries (max_attempts_per_class=2, backoff capped at 5s). Availability-
  class model errors (transient/unavailable/internal) now retry on their
  own deeper budget: max_model_availability_attempts=12 with a 1s..60s
  exponential backoff, riding out ~7 minutes of sustained provider
  failure. MAX_MODEL_RETRIES raised 8 -> 16 to let the strategy govern.

- DefaultBudgetStrategy's iteration_limit=32 failed closed mid-task with
  no synthesis (llm_calls in failed bench tasks clustered at exactly
  63/64/127/128). The default is now DEFAULT_ITERATION_BACKSTOP=1024
  (subagent 16 -> 256), documented as a runaway backstop: operational
  bounds are the resource budget system and stop-condition strategy.

New seam mirroring IRONCLAW_REBORN_PLANNED_DEFAULT_ITERATION_LIMIT:
IRONCLAW_REBORN_MODEL_AVAILABILITY_RETRY_ATTEMPTS ->
DefaultPlannedRuntimeConfig.planned_model_availability_retry_attempts ->
families::default_with_overrides. The integration group harness pins
attempts=1 so scenarios that deliberately script provider failures
(failure_category_demasked) reach Failed in seconds, not minutes; the
availability-retry tests run under paused tokio time.

Family fingerprint digests regenerated for the new strategy parameters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Surface tool-failure reasons to the model for shell and coding tools

Benchmark traces showed the model retrying identical failing calls blind:
builtin.shell parameter errors and coding-tool path rejections reached it
as a bare category ("the tool input could not be encoded") because the
handlers built FirstPartyCapabilityError/CodingCapabilityError with no
safe_summary — the model-visible Diagnostic detail channel downstream was
already wired but starved (one agent burned 13 apply_patch calls against
an out-of-scope /testbed path with empty errors).

- shell.rs: shell_error/process_error now carry the concrete reason
  ("missing 'command' parameter", timeout duration, spawn failure),
  bounded to 512 chars. The strict safe-summary validator still falls
  back to the fixed category string; the reason always survives on the
  secret-scrubbed diagnostic channel.
- coding/paths.rs: scoped-path rejections name the offending path and
  the available scoped roots; permission rejections say the operation is
  not permitted on that mount.

Covered at the dispatch tier (coding state dispatch, host-runtime
invoke_capability) per test-through-the-caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Run agent_loop scenario test targets under paused tokio time

The deep availability-retry backoff added for provider-outage ride-out
made outage-scripting scenario tests sleep for real: safety_nets alone
took ~423s (the exact cumulative backoff schedule) because scripted or
script-exhausted model errors now retry for minutes. Pause the clock on
all executor scenario targets — they drive the in-process mock host
exclusively, so timers auto-advance and the suites return to seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fail fast when no LLM provider is configured instead of riding availability retries

The placeholder unconfigured provider's RequestFailed was mapped through the
catch-all Unavailable arm, so the new deep availability retry budget rode a
permanent configuration fault through ~7 minutes of exponential backoff.
Users with no LLM configured waited minutes for an error that retrying can
never fix, and the Reborn CLI smoke tests that pin fast nonzero exits timed
out (the 4 failures on CI run 29136954176).

Map errors carrying the shared UNCONFIGURED_PROVIDER_ID to
CredentialUnavailable, which is unclassified in loop recovery and therefore
terminal on first sight; the Settings → Inference hint travels on the
scrubbed detail channel. The provider id moves to a shared constant in
ironclaw_llm so the composition placeholder and the runner mapping cannot
drift.

Regression tests: unconfigured_provider_error_maps_to_credential_unavailable_
not_availability and unconfigured_provider_detection_requires_the_placeholder_
provider_id in model_gateway.rs; the existing smoke tests
(*_exits_nonzero_when_runtime_does_not_produce_reply) pin the fast-fail at
the caller tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Derive override-built default-family replay identity from resolved config

families::default_with_overrides swapped budget/recovery strategies but
reused the planner's static version digest, so an overridden composition
carried the pure-default replay identity — violating the component-identity
contract (family.rs: the digest identifies replay-relevant configuration).

- Turn the cfg(test) fingerprint const into a runtime
  default_family_fingerprint(iteration_limit, model_availability_attempts)
  builder; override-built families hash it with their resolved values at
  composition time (BLAKE3, same path as the pinned const). The pure-default
  composition keeps the static DEFAULT_FAMILY_DIGEST, and overrides spelling
  out the production defaults hash to that same digest.
- Collapse the two Option args into a FamilyOverrides struct and drop the
  now-dead (None, None) branch in the runner's registry factory.
- Tests: digest differs per override knob and is deterministic; explicit
  production defaults reproduce the static digest; an attempts=1 override
  reaches the composed recovery strategy (one retry then abort).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Let the recovery strategy own the model retry guard; wake backoff on cancel

Two model-stage fixes from the PR 5959 review:

MAX_MODEL_RETRIES=16 silently capped any configured availability budget of
16+: the retry loop fell through to a generic ModelError exit with
FailedExitDetails::default() — no failure category, no diagnostic ref —
before the strategy could reach its own Abort. The executor now derives the
loop bound from the composed strategy via
RecoveryStrategy::max_total_model_attempts() (DefaultRecoveryStrategy
computes it from its per-class + availability budgets with margin), so
every accepted override reaches the strategy's abort boundary. The
contract-bug fall-through now carries the last observed model error's
category and diagnostic ref instead of empty details.

The availability backoff sleep (up to 60s per attempt) was not
cancellation-aware: a cancel request could wait out the full delay. The
sleep now selects over the host's cancellation_requested() future (same
pattern as the prompt-compaction and failure-explanation waits), and a
boundary cancel check right after the alteration turns the wake into a
checkpointed Cancelled exit without issuing another model call.

Tests (paused tokio time): an availability budget of 20 — past the old
executor cap — fails with the strategy's model_unavailable category and
diagnostic ref after exactly 21 model calls; cancellation requested during
the first 1s backoff exits Cancelled without riding out the sleep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Clarify DEFAULT_ITERATION_BACKSTOP doc: resource budgets are not yet enforced

The doc claimed operational bounds come from the resource budget system,
but ResourceBudgetPolicy.max_model_calls and the wall-clock cap are defined
and not applied; until they are, this backstop and the stop-condition
strategy are the only live ceilings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Carry tool-failure reasons to the model past the strict summary validator

PR 5959's headline feature (model-visible tool-failure reasons) never
reached the model for path-bearing reasons: LoopSafeSummary rejects
path/payload delimiters and newlines, and dispatch_failure_message
silently degraded every such reason to the generic category sentence
before it could reach the diagnostic channel.

- production.rs (failure_from): a host-authored safe_summary that fails
  LoopSafeSummary validation is preserved as the new
  DispatchFailureDetail::Diagnostic instead of being dropped; the
  message keeps the fixed category sentence (host-authored, Invariant 2).
- capability_port.rs: maps the Diagnostic detail into the model-visible
  CapabilityFailureDetail::Diagnostic, scrubbing secret values and
  normalizing control characters the observation validator rejects (so
  one stray escape byte cannot drop the whole observation); newlines
  are preserved. The RetrySameCall arm now forwards structured detail
  too.
- coding/paths.rs: scoped-path rejection summaries render the path and
  available roots delimiter-free ("path testbed replacer.go",
  "available roots: workspace") so they pass the strict validator —
  FilesystemDenied surfaces as a Denied loop outcome whose only
  model-visible channel is the summary itself.
- shell.rs: bounded_failure_reason documents the (now real) diagnostic
  flow; truncation remains char-based (no byte-boundary panics).

Regression tests:
- coding/mod.rs: out-of-scope rejection summary passes LoopSafeSummary
  and names path + roots; new read-only-mount write denial test pins
  FilesystemDenied with an actionable validated reason.
- host_runtime caller tier (first_party_coding_tools.rs): out-of-scope
  read and read-only write driven through invoke_capability, asserting
  the loop-boundary message survives validation and names path/root.
- host_runtime caller tier (first_party_builtin_tools.rs): shell
  sensitive-path rejection reason rides the Diagnostic detail while the
  strict summary channel stays path-free.
- production.rs: failure_from preserves rejected summaries (path +
  newline) on the Diagnostic detail and leaves validator-safe summaries
  on the message alone.
- capability_port.rs: Diagnostic detail reaches the model scrubbed
  (path kept, newline kept, control char normalized, secret redacted);
  empty-after-normalization diagnostics are dropped.
- shell.rs: bounded_failure_reason boundary tests (exactly 512 kept,
  513 truncated with ellipsis, multibyte truncation on char
  boundaries) and producer pin that paths/newlines are not
  pre-sanitized away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pin availability retries to 1 in the parity-QA binary E2E harness

The reborn_parity_qa harness scripts deliberately-failing replay gateways
(exhausted steps, mismatched requests) whose errors are availability-class;
the production 12-attempt backoff budget rode them for minutes and
reborn_trace_error_path_parity timed out waiting for Failed. Pin
planned_model_availability_retry_attempts=1 in the harness runtime config,
mirroring the integration group harness's env pin. The parity suite passes
in ~1s again; the pinned test is the regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fail fast on the stub gateway and pin retries in scripted-outage tests

Three more real-time test paths rode the new 12-attempt availability
backoff for minutes (composition-core / reborn-core CI buckets):

- The substrate-only StubGateway returned Unavailable for a missing LLM
  gateway — a build-configuration fault retrying can never fix. It now
  returns CredentialUnavailable (unclassified in recovery, terminal),
  matching the placeholder-provider fail-fast.
- send_user_message_preserves_model_unavailable_after_retry_budget
  scripts a deliberate outage; RebornRuntimeInput gains a test-support
  with_model_availability_retry_attempts override (wins over the env
  knob) and the test pins attempts=2, keeping its retry-then-fail pin.
- turn_runner_worker_full_reborn_fails_cleanly_when_model_provider_is_offline
  now builds its registry via build_loop_family_registry_with_overrides
  with attempts=2 for the same reason.

The three tests pass in ~3s each again; they are the regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update stub-gateway failure pins to model_credentials_unavailable

The stub gateway now reports a configuration fault instead of an
availability blip; the two runtime.rs pins (and one comment) tracking
its failure category move from model_unavailable to
model_credentials_unavailable accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Detect prompt-cache breaks and stop doomed compaction loops

Benchmark analysis showed the provider KV cache hit rate collapsing
from ~82% to 29% as reborn agent runs grow past ~200 model calls,
roughly 3.5x input cost. Two defenses:

ironclaw_runner model gateway: track per-run cache_read continuity
across completed provider calls. Every call emits a debug-level
per-call cache series (cache_read / cache_creation / input tokens);
a drop of BOTH >5% relative and >10K tokens absolute against the
previous call in the same run warns "prompt cache break detected"
with cheap attribution hints: tool-definition list hash changed,
system prompt hash changed, seconds since last call. The pure
threshold decision (is_prompt_cache_break) is unit-tested at its
edges; per-run state lives in a bounded Mutex<HashMap> keyed by
TurnRunId on each gateway.

ironclaw_agent_loop compaction circuit breaker: when 3 consecutive
compactions complete without the post-compaction prompt estimate
dropping below the trigger threshold (compaction fired and didn't
help), a one-way breaker in the CompactionStrategyState slot opens
and compaction stays disabled for the remainder of the run, with a
single tracing::warn!. Claude Code measured ~250K wasted API
calls/day from exactly this compact-recompact doom loop before
adding a breaker. State follows the typed-slot pattern (strategy
reads, executor swaps); CompactionDecision::Trigger now carries the
trigger threshold so the executor can score effectiveness. All
compaction defaults (128000/20000/8000/...) are unchanged; the
family fingerprints gain ineffective_trip_limit=3 and their BLAKE3
digests are regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extract prompt-cache telemetry into a private model_gateway submodule

Review findings N6/N7 on #5975: the ~350 lines of prompt-cache
continuity tracking (thresholds, break detection, observation types,
per-run scope eviction, logging) lived inline in the 3,300-line
model_gateway.rs and PromptCacheActivityLog was needlessly pub.

Pure move into model_gateway/prompt_cache_activity.rs (unit tests
included); the gateway now only constructs PromptCacheCallScope and
records completed calls. All moved types are pub(super) at most, so
PromptCacheActivityLog is no longer part of the crate's public API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Record prompt-cache breaks at debug level and cover tool-capable recording

Review findings N1-N4 on #5975:

- N4: the cache-break record logged at warn!, but repo logging rules
  reserve info!/warn! for user-facing REPL output — internal
  diagnostics must use debug!. Downgraded, and renamed the gateway test
  to gateway_records_prompt_cache_break_within_a_run with a logs_assert
  that pins the record to a non-warn level.
- N1: added a gateway integration test driving
  stream_model_with_capabilities with two same-run tool-capable calls,
  a 200K->50K cached-read collapse, and a changed advertised tool
  surface — pinning ModelCallCacheUsage::from_tool_response recording
  and tool-surface break attribution on the tool-capable path.
- N2: extended gateway_repairs_oversized_provider_tool_arguments_
  before_registration with scripted cache usage so the repair-retry
  recording seam is covered: both calls must record, and the collapse
  across the retry is a break with an unchanged request shape.
- N3: extended the classification unit test with a changed-system-
  prompt break so system_prompt_changed=true attribution is pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix compaction circuit breaker: forced bypass, summary-aware effectiveness, trigger-kind baselines

Fixes the three blocking review findings on #5975 (B1/B2/B3):

- B1: an open breaker suppressed forced/recovery compactions.
  force_compact_on_next_iteration is how ContextOverflow recovery
  (RetryAlteration::ShrinkContext) and byte-cap overflow request a
  shrink; with the breaker open they silently no-oped, the same
  oversized prompt was rebuilt, and the retry budget burned to abort.
  DefaultCompactionStrategy::can_evaluate now checks the force flag
  before the breaker — the breaker only gates automatic
  threshold-triggered compaction.

- B2: effectiveness was measured right after retain_after_sequence,
  before the injected summary landed in the prompt, so a huge summary
  that kept the prompt oversized still counted every compaction as
  effective and the doom loop persisted to the iteration backstop.
  PromptCompactionStep now stashes the baseline on the new
  compaction_state.pending_effectiveness_baseline slot; the executor
  consumes it via observe_pending_compaction_effectiveness once the
  prompt bundle is next rebuilt (immediately after the in-pipeline
  rebuild, or on the next iteration's candidate build for
  compaction-only SkipModel turns), so the comparison sees the
  summary's tokens.

- B3: forced compactions were judged against the unrelated
  visible-transcript threshold, letting a completely ineffective forced
  compaction reset the counter. CompactionDecision::Trigger now carries
  a trigger-kind-specific CompactionEffectivenessBaseline:
  TriggerThresholdTokens for automatic triggers,
  PreCompactionPromptTokens (did the prompt actually shrink?) for
  forced ones.

Also downgrades the breaker-opened log from warn! to debug! (N5):
internal loop diagnostics must not render into the REPL/TUI.

Tests (red-first where the old behavior was pinned):
- prompt_stage_circuit_breaker_disables_compaction_after_repeated_
  ineffective_runs now drives three REAL ineffective compactions
  through the prompt stage (no seeded counter) with a retained tail
  below the threshold so only the summary-aware measurement trips the
  breaker, and asserts the fourth threshold overflow is suppressed.
- prompt_stage_forced_compaction_bypasses_open_circuit_breaker pins
  B1+B3 at the executor tier: forced compaction runs with the breaker
  open and an effective shrink (260 -> 140) resets the counter even
  though 140 is still over the 90-token threshold.
- Strategy tests updated: forced-bypass triggers for both
  DefaultCompactionStrategy and ActiveTaskPreservingCompactionStrategy,
  threshold-gated skip kept, slots-level forced-baseline coverage, and
  checkpoint back-compat for the new pending slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Advertise Reborn skills as a one-line listing; load bodies on activation

Reborn injected the FULL SKILL.md body of every keyword-scored skill
into the prompt on every run — ~7K tokens of mostly irrelevant
instructions per turn (measured in claw-swe-bench traces) — and the
skill section churned whenever scores shifted, defeating prompt-cache
stability. Flip to the Claude Code model: advertise skills as one
`- name: description` line each and load a body only when the model
invokes it via builtin.skill_activate.

- `SelectableSkillContextSource` gains `SkillInjectionMode`:
  * `Listing` (Reborn default, `IRONCLAW_REBORN_SKILL_INJECTION`
    overridable to `full`): non-activated skills collapse into ONE
    discoverable "available-skills" candidate — a fixed header pointing
    at builtin.skill_activate plus one line per skill (250-char
    description cap, 100-entry cap). Full bodies inject only for
    explicit $name mentions and model-selected activations. Keyword
    scoring still runs and RANKS the listing; criteria selections no
    longer consume the model's activation budget.
  * `Full` stays the library default so non-Reborn consumers and the
    skill-execution capture plan keep their existing semantics.
- Prompt build and by-ref model-message resolution rebuild the snippet
  set independently, so both now derive body-eligibility and listing
  ranking from the merged active plan — identical sets, stable
  msg:snippet refs.
- `SkillContextService` safe summaries are now the bounded first line
  of the description instead of the full text: the multi-skill listing
  (5.6KB) blew past MODEL_SAFE_SUMMARY_MAX_BYTES and killed runs at the
  prompt stage — a latent bug for any long skill description.

v1 (`src/`) skill behavior is untouched.

Tests: reborn_integration_skill_activate pins the one-liner listing
(body absent pre-activation, present post-activation) at the captured
model-request seam; new selector listing-mode tests in
ironclaw_first_party_extension_ports; safe-summary regression in
skill_context_service_contract; env parse + config propagation tests in
ironclaw_reborn_composition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Upgrade criteria-listed skills to ModelSelected on later skill_activate

merge_active_plan deduped merged activations by bundle id alone, so a
skill already in the run plan as a listing-only ActivationCriteria entry
silently dropped a later ModelSelected activation for the same bundle:
the mode never upgraded, body_eligible_bundle_ids never included it, and
the SKILL.md body never injected — while the skill_activate tool still
reported "activated 1 skill(s)" because the merged plan matched by name.

Mode-priority merge: when the incoming activation is body-eligible
(ModelSelected/ExplicitMention) and the existing entry for the same
bundle is ActivationCriteria, upgrade the existing entry's mode in
place; never downgrade. Regression test drives record_user_message
(criteria match) then activate_skills_for_run and asserts the body
injects on the next prompt build and leaves the listing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Hash full model content into skill snippet model-message refs

skill_snippet_model_message_ref hashed only [snippet_ref, safe_summary,
ordinal], and the safe summary is first-line-only (capped 256 chars).
The available-skills listing's first line is a constant header, so
adding, removing, or reordering listed skills changed the listing body
without changing the ref — a stale ref resolved against the new content
instead of failing closed.

Include the full model_content in the hashed fields. Refs are per-run
ephemeral (built at prompt time, resolved live by
instruction_snippet_messages_by_ref in the same run; never persisted —
the executor's message index stores only sequence/kind/token counts), so
the deliberate one-time hash rotation is safe; pinned ref literals in
the turns/loop-support contract tests are updated to the new layout.
Regression tests pin that listings differing only after the first line
produce different refs and identical content produces a stable ref.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Require read-before-edit and reject stale edits in reborn coding tools

Benchmark traces show models editing files from a stale in-context view
and blindly overwriting files they never read: builtin.apply_patch and
builtin.write_file had no guard against either, unlike Claude Code's
Edit tool (read-before-edit + mid-air collision detection). This brings
the reborn coding tools to parity.

read_file now records a content fingerprint (cheap std hash of the raw
bytes it already holds) per scope + resolved virtual path in a bounded
registry on CodingCapabilityState, shared across concurrent runs exactly
like edit_locks. Only a full read (no offset/limit window) records state.

write_file and apply_patch on an EXISTING file now reject with a
recovery-worded, model-visible safe summary when:
- no read was recorded ("read it with read_file before editing it"), or
- the file's current fingerprint no longer matches the recorded one
  ("the file changed since it was last read; read it again ...").

Creating a new file needs no prior read, a successful edit refreshes the
fingerprint so chained edits don't re-trip, and write-only mounts are
exempt (the model cannot read there, so blind overwrite is the intended
mode). Eviction from the bounded registry fails safe: a missing entry
only forces a fresh read_file.

Regression tests: dispatch-tier tests in coding/mod.rs pin all five
behaviors (unread-write rejection, read-then-patch, out-of-band
staleness, new-file write, chained edits); host_runtime caller-tier
tests updated to seed read state through the public read path, and the
former builtin_apply_patch_accepts_unique_match_without_prior_read pin
is inverted to builtin_apply_patch_requires_prior_read_of_existing_file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Only record read-before-edit state for complete, untruncated reads

PR 5978 review finding 2 (ironloopai, MEDIUM): read_file recorded the
read-before-edit fingerprint whenever the call had no explicit
offset/limit, BEFORE the default output truncation was applied. A
default read of a file over the 2,000-line or 64 KiB output cap
returned partial content with truncated=true, yet still unlocked
whole-file write_file/apply_patch - violating the guard's own stated
invariant that only a full read proves the model has seen the file.

read_file now computes the rendered output first and records the
fingerprint only when the read had no explicit range AND was not
truncated at the line or byte cap (missing flag fails safe as
truncated). The read-before-edit rejection message now also explains
that ranged and truncated reads do not count, so the model understands
why its earlier read did not unlock the edit and that a file too large
to read in full cannot be blind-edited with these tools.

Regression tests (caller-tier, driven through the composed host
runtime in first_party_coding_tools.rs):
- default read of a >2,000-line file (line truncation) does not unlock
  write_file, and an explicit offset/limit read wide enough to cover
  the file still does not unlock it
- default read of a >64 KiB-rendered file (byte truncation) does not
  unlock apply_patch, even when the patch anchor was inside the
  visible window
- rejected edits leave the file untouched and carry the model-visible
  full-read guidance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scope coding read-before-edit state to the loop turn-run

PR 5978 review finding 1 (ironloopai, HIGH): CodingReadScopeKey keyed
read-before-edit state by tenant/user/agent/project/mission/thread
only, and CodingCapabilityState lives in the long-lived
BuiltinFirstPartyTools handler. A read_file recorded in one run could
therefore authorize write_file/apply_patch in a LATER run that never
read the file, as long as the content fingerprint still matched.

Security framing: the fingerprint check already guarantees content
equality at edit time, so this is a policy tightening - the model must
have SEEN the file within the current run - not a data-corruption fix.

Why a new wire field: the reviewer suggested keying by
request.scope.invocation_id, but invocation_id is minted per tool call
(InvocationId::from_uuid(activity_id) in invocation_context_from_visible),
so keying on it would break the feature entirely - the read and the
subsequent edit are always different tool calls. Nothing between
thread_id (spans many runs) and invocation_id (one tool call) existed
on the wire, so this adds the prompt-visible turn-run identity that the
loop host already owns (LoopRunContext::run_id) as an optional,
host-stamped RunId, mirroring the existing authenticated_actor_user_id
plumbing hop for hop:

  ExecutionContext.run_id (stamped in invocation_context_from_visible)
    -> CapabilityDispatchRequest.run_id (capability host)
    -> RuntimeAdapterRequest.run_id (dispatcher)
    -> FirstPartyCapabilityRequest.run_id (first-party adapter)
    -> CodingCapabilityRequest.run_id -> CodingReadScopeKey.run_id

run_id is serde(default) so persisted legacy contexts still
deserialize; non-loop callers (system services, spawned processes,
one-shot product invocations) carry None, which is its own key bucket,
never a wildcard. Reads recorded in run A neither unlock run B nor the
None bucket, and vice versa; a read in tool-call N still unlocks an
edit in tool-call N+2 of the same run.

Regression tests:
- caller-tier (first_party_coding_tools.rs, through the composed host
  runtime): read in run A, write_file/apply_patch with a same-identity
  scope carrying a different run_id are rejected with the
  read-before-edit error and leave the file untouched; a later tool
  call (fresh invocation_id) of the SAME run still edits, pinning
  run-not-invocation granularity
- loop_support: invocation_context_from_visible stamps run_id from
  LoopRunContext::run_id, so a dropped stamp cannot silently collapse
  every run into the shared None bucket
- host_api: legacy ExecutionContext payloads without run_id (and
  authenticated_actor_user_id) still deserialize

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Surface new post-edit check diagnostics on reborn coding edits (#5979)

* Surface new post-edit check diagnostics on reborn coding edits

Benchmark failure analysis shows agents breaking adjacent code without
noticing: a fix passes its own test while a sibling test starts failing,
and nothing in the tool loop tells the model. Mirror Claude Code's
baseline-diff diagnostics push with a minimal reborn seam: after a
SUCCESSFUL builtin.write_file / builtin.apply_patch dispatch, run an
operator-configured check command through the invocation's
RuntimeProcessPort and append only the NEW diagnostic lines to the
edit's model-visible output as a post_edit_check field.

Config seam: IRONCLAW_POST_EDIT_CHECK (shell command; feature off when
unset/blank) and IRONCLAW_POST_EDIT_CHECK_TIMEOUT_SECS (default 30).
Composition resolves it once via the module-owned
PostEditCheckConfig::from_env (build_local_runtime ->
HostRuntimeServices::with_post_edit_check) and threads it through
LocalInvocationServicesResolver into InvocationServices, mirroring
unsafe_raw_diagnostics_allowed - per-call handlers never read env.

Execution seam: the coding fallback arm of
BuiltinFirstPartyTools::dispatch, where the already-resolved
services.process port sits next to the dispatch result. The check runs
with the first read+write mount alias as workdir so the process port
resolves it exactly like a shell workdir (/workspace -> the host
workspace root); read-only coding tools never trigger it.

New-only filtering: a bounded per-scope seen-line registry (same scope
key dimensions as the coding read-state registry; 500 lines/scope FIFO,
512 scopes) reports up to 30 lines / 4000 bytes per edit with a
"+N more new lines" note, marking only reported lines as seen so
trimmed lines surface on a later check.

The check is advisory and never fails the edit: no new findings ->
{"exit_code": N} only; timeout -> {"timed_out": true}; a check that
cannot run at all is debug-logged and omitted.

v1 limitation (documented in the module): the seen-set is global per
scope with no per-file clearing, so a diagnostic that disappears and
reappears unchanged is not re-reported until evicted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Gate the post-edit check behind the process policy and account its spawn

The IRONCLAW_POST_EDIT_CHECK command ran through the invocation's default
process port from write_file/apply_patch plans that declare only
filesystem effects, so under ProcessBackendKind::None or a tenant-sandbox
policy the check spawned a host process outside process-backend selection,
and the spawn was never accounted.

LocalInvocationServicesResolver now populates
InvocationServices::post_edit_check only when the plan's process policy
resolves local host execution (ProcessBackendKind::LocalHost under
DeploymentMode::LocalSingleUser — the same arm that hands the local port
to process-requiring plans like builtin.shell, now shared via one
predicate). Under any other backend the advisory check is withheld, not
rerouted. A check that does run is accounted as process_count=1 on the
edit's ResourceUsage, mirroring builtin.shell. Default local-dev behavior
is unchanged.

Regression tests: resolver-tier coverage of the LocalHost/None/
TenantSandbox/hosted-LocalHost gating, caller-tier write_file dispatch
under a no-process policy and a tenant-sandbox policy (asserting zero
local-host and zero sandbox-port invocations), and process accounting
asserted in the existing post-edit-check findings test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(reborn): PR #5978 review + isolate post-edit check per tenant

Read-before-edit guardrail fixes:
- Split CodingEditLockKey (run-agnostic) from CodingReadScopeKey
  (run-scoped): concurrent runs editing the same path now contend on one
  edit-lock stripe. Folding run_id into the lock key sent them to
  different stripes, so both could pass their fingerprint check and
  overwrite each other (lost update). (ironloop)
- apply_patch reuses verify_read_before_edit, sharing the recorded-read /
  size-limit / re-read / fingerprint checks with write_file. (coderabbit)
- The post-edit check runs in the writable mount backing the edited file,
  not an arbitrary first writable mount. (coderabbit)

Post-edit check multi-tenant isolation (ironloop/coderabbit #4):
- The check no longer runs on the deployment-blind local process port.
  The invocation-services resolver now routes it to the port matching the
  plan's process backend -- the tenant sandbox under HostedMultiTenant --
  so a tenant's command runs isolated in that tenant's own sandbox, never
  on the shared provider host, and is disabled when no backend can run it
  in isolation.
- Wired the operator check into production composition (previously
  local-dev only).

Regression tests: cross-run edit-lock serialization, run-agnostic lock
key vs run-scoped read key, edited-mount selection + alias-boundary
match, and resolver isolation routing (local host vs tenant sandbox vs
disabled).

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

* fix(reborn): reconcile post-edit-check merge with main (#5978)

Merging main (PR #5979, the post-edit-check diagnostics PR) into this
branch surfaced two integration gaps that were failing CI:

- main's new `first_party_tools/http.rs` save-request test constructed
  `FirstPartyCapabilityRequest` / `InvocationServices` without this
  branch's added `run_id` / `post_edit_check` fields, breaking the
  default-feature build. GitHub's `pull_request` CI builds the PR merged
  into base, so it hit this before a local merge did.

- `builtin_edit_tools_disable_post_edit_check_under_tenant_sandbox_policy`
  (introduced by #5979) encoded the pre-rework behavior where the check
  was withheld outright under a tenant-sandbox policy. This branch's
  rework (82d7148) instead routes the check to the tenant-sandbox
  process port so it runs ISOLATED in the tenant's own sandbox — the
  behavior the authoritative resolver test
  `local_resolver_routes_post_edit_check_to_the_deployment_isolated_process_port`
  pins. #5979's test couldn't be updated in that commit because it only
  existed on main. Rewrote it as
  `builtin_edit_tools_run_post_edit_check_in_tenant_sandbox_not_on_local_host`,
  asserting the check runs through the sandbox port (surfacing its
  diagnostics) and never escapes onto the local host port.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai coderabbitai Bot mentioned this pull request Aug 11, 2026
15 of 29 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants