# Clavue v8.13.3 — Heap pressure feedback ladder: soft signals over hard blocks

Released: 2026-05-19

## Why this exists

v8.13.2 (and back to v8.10.x) treated a single 80% heap watermark as a
hard refusal signal. A user trace from yesterday made the misclassification
obvious:

```
Heap pressure is critical (9986MB / 12480MB)
Refusing to send the next request to avoid an OOM crash.
```

That is **80.02 %** of `--max-old-space-size=12288`. V8's mark-compact GC
routinely lets the working set hang in the 80-90 % band while it decides
whether to compact — that's the *intended* operating point of the
heuristic, not a precursor to `Ineffective mark-compacts near heap limit`.
Refusing the request at 0.8 × limit converts normal GC behavior into a
visible failure mode for users with long sessions, large repos, or large
contexts.

The fix isn't to raise the number. The fix is to stop treating one
threshold as a binary signal. v8.13.3 splits the single gate into a
**three-tier feedback ladder** so we can apply soft pressure first and
only refuse when V8 is genuinely out of room.

## The ladder

```
                  ┌─────────────────────────────────────────────┐
                  │ heapUsed ≥ critical (≈80%, autocompact ran) │
                  └───────────────────────┬─────────────────────┘
                                          │
                          forceMajorGc()  │  (cheap, <50ms)
                                          ▼
                  ┌─────────────────────────────────────────────┐
                  │ post-GC: heap dropped below critical?       │
                  └────┬──────────────────────────────┬─────────┘
                       │ yes                          │ no
                       ▼                              ▼
              ┌──────────────────┐       ┌─────────────────────────┐
              │ advisory: silent │       │ heapUsed ≥ hardBlock    │
              │ continue request │       │ AND headroom ≤ hardHead │
              └──────────────────┘       └────┬───────────────┬────┘
                                              │ no            │ yes
                                              ▼               ▼
                                     ┌──────────────────┐  ┌──────────────────┐
                                     │ warning: yield   │  │ hard-block: yield│
                                     │ system message,  │  │ error + REFUSE   │
                                     │ STILL SEND       │  │ (heap_soft_block)│
                                     └──────────────────┘  └──────────────────┘
```

| Tier         | Condition                                                                              | What happens                                                |
|--------------|----------------------------------------------------------------------------------------|-------------------------------------------------------------|
| advisory     | `heapUsed ≥ critical (0.80 default)`                                                  | Force major GC, re-measure. **Silent. Continues.**          |
| warning      | post-GC `heapUsed ≥ critical` **AND** `heapUsed < hardBlock`                          | One `warning`-level system message. **Request still sends.**|
| hard-block   | `heapUsed ≥ hardBlock (0.95 default)` **AND** `availableHeadroom ≤ 256MB`             | `error` system message + `return { reason: 'heap_soft_block' }` |

The hard-block check is a **conjunction**, not a disjunction. That is the
critical change: a 96 % ratio on a 64 GB heap still leaves ~2.5 GB of
headroom — not an OOM precursor. Conversely a tiny heap can have low
absolute headroom without being near the ratio ceiling. Requiring both
keeps the refusal robust at both extremes.

## What does Codex / Claude Code do here?

We checked the two adjacent runtimes before settling on the ladder:

| Runtime      | Binary shape                                  | Heap soft-block?            |
|--------------|-----------------------------------------------|-----------------------------|
| **Codex**    | Native Rust binary, no V8 / no JS heap       | N/A — no concept            |
| **Claude Code** | Bun SEA + JSC. Dynamic `heap_size_limit = min(memory.peak * 10, totalmem())` | None. `strings` of the shipped binary returns zero matches for `Refusing to send`, `heap pressure`, `isAboveCritical`. |
| **Clavue 8.13.2** | Node + V8 with `--max-old-space-size=8192` (auto-grown to 12288 on big sessions) | Single 80 % gate, refuses outright |
| **Clavue 8.13.3** | Same Node + V8                            | Three-tier ladder (this release) |

The lesson: a native binary doesn't have this problem; a JSC binary
sidesteps it via dynamic limits. While we're still on Node + V8, the
correct answer isn't to bolt on stricter limits but to stop treating GC
lag as imminent OOM. v8.13.3 brings us closer to the Claude Code behavior
(no refusal in the warning band) without sacrificing the truly-near-OOM
guard.

A follow-up minor (v8.14) will evaluate switching the shipped Clavue
binary to Bun SEA so we inherit JSC heap behavior wholesale.

## Why a ladder, not just a higher number

Two complementary perspectives shaped the design:

- **Karpathy** ("the bitter lesson of agents"): hard refusals are easy
  to write but they push the recovery burden onto the user. Soft
  signals — observability + autonomous compaction — let the agent stay
  on task across mode boundaries.
- **姚顺宇** (RL framing of agent loops): the policy should *learn from*
  near-misses, not be terminated by them. A warning that still lets the
  request through preserves the trajectory; only an unrecoverable state
  should end it.

The advisory band is the agent self-correcting (GC). The warning band
keeps the trajectory alive with a user-visible nudge. The hard-block
band fires only when V8 is actually about to crash.

## Configuration

All new envs are optional; defaults match the behavior described above.

| Env                                       | Default | Effect                                                                 |
|-------------------------------------------|---------|------------------------------------------------------------------------|
| `CLAVUE_HEAP_HARD_BLOCK_PCT`              | `95`    | Hard-block ratio (0-100). Clamped to ≥ critical ratio.                |
| `CLAVUE_HEAP_HARD_BLOCK_MB`               | —       | Absolute hard-block threshold in MB (overrides PCT). Clamped to ≥ critical. |
| `CLAVUE_HEAP_HARD_BLOCK_HEADROOM_MB`      | `256`   | Available-headroom floor for hard-block. Clamped to 64-512.            |
| `CLAVUE_DISABLE_HEAP_SOFT_BLOCK`          | unset   | Existing escape hatch — still removes the **entire** ladder.           |

Unchanged: `CLAUDE_CODE_HEAP_AUTOCOMPACT_MB`/`_PCT`,
`CLAUDE_CODE_HEAP_CRITICAL_MB`/`_PCT`/`_HEADROOM_MB`. The 0.8 critical
ratio is still consulted by autocompact / `relieveAutoCompactHeapPressure`;
only the query loop's interpretation of it changed.

## Files touched

- `src/utils/heapPressure.ts` — added `DEFAULT_HEAP_HARD_RATIO`,
  `DEFAULT_HEAP_HARD_HEADROOM_MB`, min/max clamps,
  `getHeapHardBlockThresholdBytes`, `getHeapHardBlockHeadroomBytes`, and
  three new fields on `getHeapPressureState`'s return:
  `hardBlockThresholdBytes`, `hardBlockHeadroomBytes`,
  `isAboveHardBlockThreshold` (the AND-conjunction). `isAboveCriticalThreshold`
  is unchanged so `autoCompact.ts` and `compact.ts` keep working.
- `src/query.ts` — the L4 gate (~L675-770) became a three-branch ladder:
  advisory → forceMajorGc, warning → yield `'warning'` system message
  and fall through, hard-block → yield `'error'` and return
  `{ reason: 'heap_soft_block' }`.

## Verification

- `tests/heap-pressure-ladder.test.mjs`: 12 new tests covering tier
  boundaries, the AND-conjunction (ratio alone / headroom alone),
  hardBlock ≥ critical invariant, env overrides, and the query.ts
  wiring contract.
- `tests/heap-pressure.test.mjs`: unchanged, still green (existing
  autocompact/critical helpers are untouched).
- `tests/heap-soft-block-contract.test.mjs`: unchanged, still green
  (`heap_soft_block` reason still exists, just behind the tighter gate).
- Full suite: **1537 tests / 1531 pass / 6 skipped / 0 fail** (≈56 s).
- `dist/cli.js --version` → `8.13.3 (Clavue)`.

## What changes for users

- Sessions that used to fail with `Heap pressure is critical (NNNN/MMMM)`
  at 80 % will now continue past the warning band. You'll see one
  `warning`-level message suggesting `/clear` or `/compact` and the
  request goes through.
- Sessions that are genuinely on the edge (≥95 % heap **and** ≤256 MB
  free) will still be refused with the same `heap_soft_block` reason —
  this is the actual OOM precursor and Clavue still protects you from
  it.
- `CLAVUE_DISABLE_HEAP_SOFT_BLOCK=1` still works as a full escape hatch
  if you want neither the warning nor the hard-block.

## Upgrade

```
npm i -g clavue@8.13.3
# or
npx -y clavue@8.13.3
```

No config changes required.
