Skip to content

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

Merged
ilblackdragon merged 2 commits into
edit-guardrailsfrom
post-edit-diagnostics
Jul 14, 2026
Merged

ilblackdragon merged 2 commits into
edit-guardrailsfrom
post-edit-diagnostics

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

Stack 4/4 on the edit-guardrails PR.

Benchmark failure analysis shows the dominant remaining agent failure is collateral damage: a fix passes its own reproduction but breaks adjacent code the agent never re-checked. Claude Code pushes NEW diagnostics to the model after each edit (baseline→diff, deduped, capped). This is the reborn v1.

What

  • Env-gated, off by default: IRONCLAW_POST_EDIT_CHECK (operator command, e.g. cargo check --message-format=short 2>&1) + IRONCLAW_POST_EDIT_CHECK_TIMEOUT_SECS (default 30). Env read once in composition (apply_post_edit_check_from_env, following the optional-env pattern), threaded through HostRuntimeServices → InvocationServices — no per-call env reads.
  • Hook site: the coding fallback arm of BuiltinFirstPartyTools::dispatch, the one place where the successful edit result, the RuntimeProcessPort (same port builtin.shell uses, same /workspace workdir-alias semantics), scope, and mounts coexist. Only fires for write_file/apply_patch.
  • New-only filtering: per-scope seen-line registry (500 lines/scope FIFO, 512 scopes); output capped 30 lines / 4000 bytes with "+N more new lines".
  • Surfaced as a post_edit_check field on the edit's tool output: {"exit_code": N, "new_output": ...} on findings; {"exit_code": 0} when clean (token-lean); {"timed_out": true} on timeout. The check is advisory — it never fails the edit.

Testing

Red-then-green caller-tier tests drive invoke_capability through the full runtime with a scripted process port: output carries new diagnostics; a second identical check output produces no repeated lines; unconfigured → zero port invocations and no field; timeout-advisory and clean-pass cases. 10 unit tests on filter/config (dedup, caps, FIFO eviction, invalid-timeout). All three crates green; fmt + clippy clean; pre-commit safety script exit 0.

Deferred (documented): per-file seen-set clearing, hosted/multi-tenant composition wiring (local-dev only by design), resource accounting for the check run.

🤖 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: 8dde2f00a55e0768a8b47ecfc94fde9aa0d7c9c0
Result: 1 blocking finding across 1 reviewer.
Next: Address the blocking findings, push fixes, then re-run the relevant reviewer.
Updated: 2026-07-11T04:19:21.305Z

Current reviewers:

Reviewer State Verdict Findings Last update
ironloop/common-reviewer (reviewer) Completed Changes requested 1 blocking finding / 0 notes 2026-07-11T04:19:21.294Z
Reviewer summaries
Reviewer Detail
ironloop/common-reviewer (reviewer) Changes requested; 1 blocking finding; Found one blocking security/policy issue in the new post-edit check path.
Recent activity
Time Reviewer State Detail
2026-07-11T04:14:12.426Z ironloop/common-reviewer (reviewer) Queued Accepted review request for head 8dde2f0.
2026-07-11T04:14:12.426Z ironloop/common-reviewer (reviewer) Queued Waiting for this reviewer lane to become available.
2026-07-11T04:14:16.109Z ironloop/common-reviewer (reviewer) Started Reviewer worker started attempt 1.
2026-07-11T04:14:19.824Z ironloop/common-reviewer (reviewer) Workspace ready Prepared isolated checkout (merge_ref) at 9aafddf.
2026-07-11T04:19:02.442Z ironloop/common-reviewer (reviewer) Running Codex is reviewing; process live; elapsed 4m 44s; timeout in 15m 16s; last heartbeat 2026-07-11T04:19:02.442Z. Codex emitted stderr output at 2026-07-11T04:18:51.535Z.
2026-07-11T04:19:21.294Z ironloop/common-reviewer (reviewer) Result captured Changes requested; 1 blocking finding.
2026-07-11T04:19:21.294Z 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.

@github-actions github-actions Bot added the size: XL 500+ changed lines label Jul 11, 2026
@coderabbitai

coderabbitai Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • staging
  • reborn-integration

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f3781ca9-cb07-4546-a7a4-46c06eb5832c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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 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 8dde2f00a55e

Head: 8dde2f00a55e0768a8b47ecfc94fde9aa0d7c9c0
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 one blocking security/policy issue in the new post-edit check path.

Findings

Blocking: 1 / Notes: 0

Blocking findings

1. ❌ [HIGH] Post-edit checks bypass process policy for edit tools

Location: crates/ironclaw_host_runtime/src/first_party_tools/mod.rs:490
write_file and apply_patch still declare only filesystem effects, so the planner sets requires_process = false. The resolver then supplies the default local host process port for those invocations, even when the runtime policy has ProcessBackendKind::None or TenantSandbox. With IRONCLAW_POST_EDIT_CHECK configured, every successful edit now runs sh -c through that port, bypassing process-effect planning, sandbox selection, and process accounting. Gate the check behind the same process policy as builtin.shell (or disable it when process execution is unavailable) and add a regression test for ProcessBackendKind::None / tenant sandbox.

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.

};
let Some(check) = crate::post_edit_check::run_post_edit_check(
&self.post_edit_check_seen,
request.services.process.as_ref(),

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 uses the invocation's process service for edit tools that do not declare a process effect. For write_file/apply_patch, requires_process is false, so the resolver hands back the default local host port even under ProcessBackendKind::None or tenant-sandbox policies. A configured post-edit check can therefore spawn a host process outside the process policy/sandbox. Please gate this through the same process planning path as shell, or disable it when process execution is not allowed.

@ilblackdragon

Copy link
Copy Markdown
Member Author

Finding addressed (22758c320):

Post-edit check now sits behind the process policy, decided at resolve time. LocalInvocationServicesResolver::resolve — the layer whose contract already owns backend-kind decisions — now shares one predicate (local_host_process_execution_permitted: ProcessBackendKind::LocalHost + LocalSingleUser deployment) between shell's requires_process arm and a filter on post_edit_check, so InvocationServices::post_edit_check is None for any plan whose policy wouldn't hand a process-requiring capability the local host port. Under ProcessBackendKind::None, tenant-sandbox, or hosted deployments the advisory check is withheld outright — no host process can spawn. Default local-dev behavior is unchanged, and no runtime branching was added to the tool handler (it consumes the resolved Option). Process accounting is also fixed: an edit whose check actually ran records process_count = 1, mirroring builtin.shell.

Regression tests as requested: resolver-tier matrix (Some only under LocalHost/LocalSingleUser; None under None, tenant-sandbox even with a sandbox port configured, and hosted deployments) plus caller-tier tests driving write_file through full runtime dispatch with counting process-port doubles under ProcessBackendKind::None and tenant-sandbox — edit succeeds, no post_edit_check field, zero invocations on either port, process_count == 0; the existing findings test now also asserts the accounting under LocalHost. No tests/integration/ case: the reborn harness runs the local-dev LocalHost deployment and cannot reach the gated policies (crate-tier fallback per .claude/rules/testing.md).

ilblackdragon and others added 2 commits July 12, 2026 22:41
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>
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>
@ilblackdragon
ilblackdragon force-pushed the post-edit-diagnostics branch from 2c2e779 to a3f2000 Compare July 12, 2026 23:00
@ilblackdragon
ilblackdragon merged commit 7f90182 into edit-guardrails Jul 14, 2026
31 of 47 checks passed
@ilblackdragon
ilblackdragon deleted the post-edit-diagnostics branch July 14, 2026 05:54
ilblackdragon added a commit that referenced this pull request Jul 17, 2026
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>
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>
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.

1 participant