Skip to content

fix(pixel): a Playground refusal that trips the run fuse still admits the write it asked for, once - #6766

Open
Lightheartdevs wants to merge 3 commits into
mainfrom
claude/pixel-router-refusal-correctable
Open

Lightheartdevs wants to merge 3 commits into
mainfrom
claude/pixel-router-refusal-correctable

Conversation

@Lightheartdevs

Copy link
Copy Markdown
Collaborator

This PR relaxes the consecutive-failure fuse in one narrow case

The run-progress fuse (RUN_PROGRESS_LIMITS.consecutiveFailures: 4) still trips at exactly the same failure as before. What changes is what happens next, in one case only:

  • The failure that tripped it was a Playground router refusal. That refusal ran nothing and its text named its own remedy.
  • The very first tool call after the trip is that remedy: a write the router will bind as the new project.
  • The model has not yet been shown the finalization instruction.

In that case, that one call runs. Every other first call gets exactly the stop it gets on main.

Why: the router refuses commands until the first project file exists, and the refusal says so ("Not run: write the first project file before running commands"). Those refusals are charged as failures, and they still are. But once four of them trip the fuse, the fuse refuses the write the refusals asked for. The model follows the instruction and is stopped for following it.

Evidence

Fleet sweep claude-open-prompts, build bfb62a2, prompt 07 "write a python script that renames all photos in a folder by date taken, and test it". Qwen3.6-35B-A3B GGUF on a Windows WSL target, session b121a8f7 (fixture tests/fixtures/photo-renamer-bfb62a2b.json). Graded PARTIAL: 8 calls, 7 failures, no file written.

# Call Result on main
1 python3 --version && python3 -c "from PIL …" refused: "Not run: write the first project file …"
2 python3 --version ran (allowed inspection)
3–5 three Pillow/piexif/exifread probes refused, same text
6 the Pillow probe again refused, same text. This is the 4th consecutive failure, so the fuse trips
7 write Playground/photo-renamer/rename_photos.py (5853 chars) refused with the finalization instruction
8 the same write refused, and the run is aborted

Replayed against main's router, call 7 is accepted as written and binds Playground/photo-renamer. Only the fuse blocked it.

Change

plugin/playground-projects.mjs

  • The two refusals before the first project file now carry correctable: <kind>:
    • 'playground-first-write': the NOT_RUN gate for exec and apply_patch;
    • 'playground-project-path': the write refused for a bare, generic or unusable path.
  • Their text is unchanged. No other refusal is marked.
  • New bindsNewProject({state, tool, params, root, existingPaths}) is a pure check. It is true only when the router would reserve and bind a new project for this call, which means all of:
    • the call is a write, direct or through tool_call (Tool Search);
    • the first project file does not exist yet;
    • the path already has the form the refusals ask for: Playground/<non-generic name>/…, optionally under /workspace/ or the configured root;
    • the path names no file read in this run (the router preserves those instead of binding).
  • The check is deliberately narrower than the router. Spellings such as playground/<name>/… or <name>/<file>, which the router also binds, are not admitted.

plugin/run-progress-budget.mjs

  • observeResult({..., correctableRefusal}) charges the failure as always. A correction pends only when all of these hold:
    • this charge tripped the global consecutive fuse;
    • the total cap was not reached;
    • the failure is not lane-classified;
    • this kind was never granted in this run.
  • pendingCorrection exposes the pending kind.
  • correctFuse(remedy) is called with the first tool call after the trip. It always spends the pending correction. When remedy is true it re-opens the response and keeps every counter:
    • the consecutive count stays at the fuse, so that call's own failure (or any other) stops the response again at once;
    • only a success resets the count, exactly as any success does.
  • beginModelRound() clears a pending correction when the round cap fires, and stop() clears it too.
  • RUN_PROGRESS_LIMITS is unchanged.

plugin/progress-finalization.mjs: disarm() returns the phase from pending to idle. pending means no tool boundary has delivered the instruction yet, so the model never saw the stop. disarm() does nothing in any other phase.

plugin/tool-loop-guard.mjs

  • At the top of decideToolCall, while a correction pends, admitRefusalRemedy decides the first call. It admits the call only when all of these hold:
    • the finalization phase is pending;
    • the call ID is not a nested tool_search_code: child;
    • Playground routing is active (the existing condition, now playgroundRoutingActive(state));
    • bindsNewProject is true.
  • Admitting calls correctFuse(true) and disarm(), and the call then goes through every normal check, routing included. Anything else calls correctFuse(false), and main's exhausted branch runs unchanged.
  • A router refusal's kind is kept by exact call ID and handed to whichever result hook counts the receipt first (after_tool_call or tool_result_persist). Only {block, blockReason} reaches the runtime.
  • While a correction pends, the tripping refusal's transcript copy keeps its refusal text instead of the stop text, because the response may still continue.
    • The live model context is unaffected (it already got the refusal).
    • If the correction is declined, later receipts carry the stop text as before.

Why "binds", then "succeeds": the router reserves the project folder and saves the binding in before_tool_call, so whether the write binds is known before it runs, and admission requires it. The write's own result then decides the rest. A success resets the consecutive count, so the ordinary fuse applies from zero. A failure is charged on top of the tripped count and stops the response immediately.

Bounds kept

  • Fuse count: unchanged. The same failure trips it, and every refusal is still charged.
  • Total cap: unchanged. A refusal whose charge also reaches failure 12 leaves no correction.
  • Round cap: unchanged. If the model call that would send the remedy exceeds 8 rounds without progress, the correction is cleared.
  • Tool-call caps and wall clock: untouched.
  • Grants: at most one per refusal kind per run. In practice at most one per run: both kinds exist only before the first binding, and the admitted write binds.
  • What gets admitted: only the first call after the trip, and only a binding write. A model that ignores the refusal is stopped at the same call with the same text as on main.
  • Nested Tool Search children, owner-cancelled runs and strict-stop runs: never re-opened. Strict-stop runs are the ones whose finalization phase is unavailable.
  • Lane fuses: not relaxed.
  • Exec signal: the model-end boundary between the trip and the admitted call signals the run's exec control, exactly as on main. That can only end a command still running from before the trip. The next exec's prepare clears the marker.

Overlap with #6749 (claude/budget-one-corrected-attempt, open, conflicting with main)

Same idea. Both PRs deal with a failure that ran or measured nothing and whose result already gives the model its correction, and give it one corrected attempt. All four RUN_PROGRESS_LIMITS stay unchanged in both.

Different mechanism:

The router's remedy can be identified before it runs (a write the router will bind), so the stricter form is possible here. An inspection's correction can only be judged by its result, which is why #6749 needs the deferral.

Naming: the vocabulary is reused (correctable, corrected attempt), but the parameter is correctableRefusal (a kind string), so it does not collide with #6749's boolean correctable/corrected. The grant is once per kind, not a per-run count like RUN_PROGRESS_CORRECTED_ATTEMPTS.

Composition: in observeResult's failure branch, run #6749's deferral and settle() first, then this PR's trip-time recording. At both call sites, pass both parameters.

Tests

tests/refusal_remedy_fuse.test.mjs adds 14 tests. The guard tests replay real hook order: model call start and end, before_tool_call, then after_tool_call and tool_result_persist. The replays run in both result-hook orders where that matters.

The recorded trace (calls 1–8):

  • Call 6 still trips the fuse.
  • Call 7 runs as written and binds Playground/photo-renamer. The run continues: call 8 revises the file, and python3 rename_photos.py --help gets the project cwd. No abort happens.
  • The ordinary fuse then applies from zero: three failures pass and the fourth stops.
  • Call 6's transcript copy keeps its refusal text.
  • The Tool Search route to the same write (tool_call {id:'openclaw:core:write'}) is admitted the same way.

A model that keeps ignoring refusals is still fused at the same bounds:

  • Replaying call 6 again gets PROGRESS_FINALIZATION_INSTRUCTION. The late write then gets RUN_PROGRESS_STOP_REASON and one abort. This is main's sequence, byte for byte.
  • These first calls after the fuse are all refused as on main, and nothing is written:
    • a bare filename, a generic folder, or Playground/<file>;
    • the lowercase playground/ spelling, or a path outside the workspace;
    • edit, apply_patch, mkdir or read;
    • a nested tool_search_code: child.
  • A forfeited answer turn (two model calls without a tool boundary) admits nothing. An owner cancel after the trip admits nothing either, and the stop is as on main.
  • Total cap: failure 12 is also the fourth consecutive refusal, and no correction pends.
  • Round cap: with 3 idle rounds first, the remedy is admitted. With 4, it falls in round 9 and gets the stop.

A refusal followed by a different failing tool still counts:

  • Three refusals plus a failed ls trip the fuse with no correction.
  • An admitted remedy that itself fails is charged on top of the tripped count and stops the next call. Nothing is granted again.

No double grant (budget unit tests):

  • A declined correction is spent.
  • A kind is never granted twice.
  • A second kind gets its own single grant.
  • A lane-classified refusal is never granted, even while the global count stands at the fuse after an admission.
  • The total cap, round cap, lanes and stop() leave no correction.

Property test. 400 seeded random traces compare a budget that gets correctable kinds with one that does not:

  • Without admissions, the two are identical at every step.
  • With admissions, these always hold:
    • 12 failures observed while open always stop the response;
    • more than 8 rounds without progress always stop the response;
    • an open response is never more than one failure past the fuse per admission in the current streak;
    • there are never more than two admissions per run (one per kind).
  • The run had 54 admissions, and 10 traces made progress after the unmarked budget had already stopped.

Other unit tests: the router marks exactly the two refusals, bindsNewProject agrees with the router's actual binding over 12 path forms (direct and wrapped), and disarm() works only from pending.

Red check. On main 5afd46c with only bindsNewProject added (so the file imports), 10 of 14 tests fail, all as expected. The 4 that pass are the "stop exactly as on main" tests: ignore, forfeited turn, owner cancel, and other first calls.

Mutation checks. Each of these 14 mutations fails at least one test:

  • dropping the pending phase check, the nested-ID check or the routing-active check;
  • dropping the per-kind grant, the total-cap exclusion, the round-cap clear or the lane exclusion;
  • not spending the correction when declined;
  • dropping disarm() or the transcript-copy exception;
  • leaking correctable to the runtime;
  • dropping the kind from either result hook;
  • admitting any write.

Full Linux pixel-agent suite (node --test tests/*.test.mjs on a Linux host from a git archive of each ref):

Ref tests pass fail
main 5afd46c (base) 3203 3165 0
this branch 37643ed 3217 3179 0
this branch + main 0a63258 (unpushed merge) 3219 3181 0

The skip count (38) is identical on all three.

Conflicts with open PRs (git merge-tree against this head)

Not covered

  • Lane fuses in mixed workspace/extension runs are not relaxed.
  • A remedy dispatched in parallel. If the runtime dispatches the remedy write before the tripping refusal's result is counted (same model message), the behavior stays as on main.

🤖 Generated with Claude Code

Lightheartdevs and others added 3 commits September 26, 2026 04:01
…tripped

The Playground router's refusals before the first project file (the
run-nothing-before-the-first-write gate and the project-path refusal) run
nothing and name their remedy: a write that binds the project. They stay
charged failures, so four of them in a row still trip the run-progress
consecutive-failure fuse, which then refused the very write they asked for
(fleet photo-renamer on build bfb62a2: call 7 blocked, run ended PARTIAL).

The router now marks those two refusals correctable by kind. When such a
refusal's charge trips the global consecutive fuse, and neither the total
cap nor both lanes are reached, the budget leaves one corrected attempt
pending, at most once per kind per run. The first tool call after the fuse
spends it: only a write the router will bind as the new project
(bindsNewProject), sent before any tool boundary delivered the finalization
instruction, re-opens the response. Its counters are kept, so that write's
failure stops the response at once and only its success resets the
consecutive count. Any other first call gets exactly the stop it gets
today. The round cap clears a pending correction; the total cap, lanes,
external stops, tool-call caps and wall clock are unchanged. While a
correction pends, the tripping refusal's transcript copy keeps its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the Tool Search route to the same remedy write, an owner cancel after
the fuse (routing off, so nothing is admitted and the stop is as on main),
and a lane-classified refusal while the global count stands at the fuse
after an admission (never granted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moves pendingCorrection and correctFuse after beginModelRound, so a change
that adds a method after laneExhausted (#6748) merges without a conflict.
No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant