Skip to content

Answer-state checkpoints, and finishing an interrupted run into an answer - #203

Merged
shanev merged 5 commits into
mainfrom
wall-clock-extract-fallback
Aug 3, 2026
Merged

Answer-state checkpoints, and finishing an interrupted run into an answer#203
shanev merged 5 commits into
mainfrom
wall-clock-extract-fallback

Conversation

@shanev

@shanev shanev commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

A run that stops short used to hand the host a fatal error and throw away everything it had done. Three related changes make an interrupted run finish into an answer, plus one bug fix found while testing them.

Answer state no longer dies with the process

Hosts embedding the Pyodide substrate can be SIGKILLed mid-execution — Pyodide cannot be preempted, so a wedged run runs no finalizer and no signal handler. Until now every answer-critical value (the draft, and whatever host state backs it) existed only in process memory until project_run_response at the very end.

Trace ABI v7 adds a checkpoint event carrying the draft plus an opaque host payload, emitted after each executed step whose draft moved. A kill becomes a render rather than a recovery.

  • Classified CONFIGURABLE, not DURABLE — it carries content, so it must be retention-gated.
  • checkpoint_seq is strictly increasing per run_id, enforced in TraceRecorder (the only per-run authority); the stateless half stays in validate_event_body.
  • payload is validated as Mapping | None and never inspected or schema-checked, matching how the relay already ferries adapter meta.
  • A raising provider still emits a checkpoint with payload: null; an unrepresentable payload drops that one checkpoint. A checkpoint can never fail a run.

Wall-clock exhaustion routes to the extract fallback

Spending budget.wall_ms ended through early_result, which hands back a fatal error alongside whatever partial answer existed — and hosts discard the run. Every iteration of real work was thrown away over a time verdict, which says nothing about whether that work was good.

Deadline exhaustion now takes the same terminal handoff as an exhausted iteration budget. Token exhaustion deliberately stays fatal: there is no budget left to pay for the extract call it would trigger.

The extract call is reserved with deadline_exempt=Truecall_root reserves through_deadline=True, which refuses once remaining.wall_ms hits 0, so without the exemption the fallback would be unreachable in exactly the case it exists to serve.

The host can now say what the engine cannot see

Two symmetric hooks, both opaque, both keeping the engine ignorant of what a host data layer is:

  • extractable_work_probe — the engine decides whether extraction is worth attempting from a retained draft or a completed step. Neither exists when generated code calls an accessor and then raises before printing. Measured on a real 314k-message corpus: a run retrieved 84 GUIDs across four ledger records and returned a bare BudgetExhausted, never attempting extraction.
  • extract_context_provider — unlocking the call was necessary but not sufficient. The extract prompt is built from draft + code + stdout, and a REPL only surfaces what the code chose to print, so extraction was handed failing code and answered InsufficientEvidence while the rows sat in the host's ledger.

extract_context_provider is deliberately narrow: observations are supplied only when the host is what made extraction possible. When the engine can already see the work, the prompt is byte-identical to before this PR — same answers, same spend, same benchmark scores. The behavior change is confined to runs that produce nothing today.

Bounded at 8k chars because the extract call is deliberately uncached (cache_anchors=None), so every character is paid at full prefill.

This is dspy.ReAct's shape rather than a new invention — it puts every tool's return value straight into the trajectory, so extraction always sees the data. dspy.RLM, being REPL-based, has exactly this gap: its variables_info is built once from the inputs and never reflects what execution retrieved.

Bug fix: emoji silently killed every checkpoint

events.ts compared Python's code-point draft_chars against JavaScript's UTF-16 String.length. Any non-BMP character made them disagree, so isRlmEvent rejected the frame and the relay dropped it before it reached the host — silently, and for every later checkpoint too, since the draft keeps the character.

Found by adversarial review, not by the suite: every fixture was ASCII. Regression tests now use emoji, astral CJK, math alphanumerics, a ZWJ sequence, and a regional-indicator flag, plus the negative case.

Testing

1261 passed, 3 skipped, ruff clean, deno test pyodide/ 60 passed.

Mutation-verified rather than assumed: removing the draft/payload gate, the provider guard, the emission guard, the repaired-code emission point, the draft_chars check, the strict-monotonic check, the host_context wiring, the 8k bound, or the narrow gate each fails a distinct test. The UTF-16 fix is verified against a real WASM-emitted frame.

Notes for review

  • ABI 6 → 7 rewrites every content-addressed scaffold manifest id. Fixture ids were re-derived programmatically via ScaffoldManifest.from_dict, not hand-edited.
  • The v7 golden corpus exercises the version bump but does not yet carry a checkpoint event — adding one renumbers seqs across a run. Say the word if the cross-language corpus should carry one.
  • The terminal fail-closed finalization execute_step does not emit a checkpoint. That follows the spec's explicit enumeration, but that path can also mutate answer['content'] — a one-line addition if wanted.
  • Cozy pins droste==0.21.1 and its host side is ready but blocked on a release carrying this.

🤖 Generated with Claude Code

shanev and others added 5 commits August 1, 2026 14:21
A run that spent `budget.wall_ms` ended through `early_result`, which hands
the host a fatal `error` alongside whatever partial answer existed. Hosts
treat `result.error` as fatal and discard the run, so every iteration of
real work was thrown away over a *time* verdict — one that says nothing
about whether that work was any good.

Deadline exhaustion now takes the same terminal handoff as an exhausted
iteration budget: it sets `terminal_handoff` and breaks, reaching the
extract pass that turns partial work into a best-effort answer with
ledger-derived citations. Token exhaustion deliberately stays fatal, since
there is no budget left to pay for the extract call it would trigger.

The extract call is reserved with `deadline_exempt=True`. `call_root`
reserves `through_deadline=True`, which refuses once `remaining.wall_ms`
reaches 0 — so without the exemption the fallback would be unreachable in
exactly the case it exists to serve. Token budget still binds, and the
caller keeps such calls to exactly one.

Terminal REPL finalization is skipped for deadline handoffs the same way it
is for `IterationLimitExceeded`: re-entering the sandbox after the deadline
cannot help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cozy tells users a best-effort answer stopped because it "ran out of
time" whenever the recovered cause is BudgetExhausted. That is only
honest while the wall clock is the sole budget resource able to reach a
recoverable terminal handoff — token exhaustion stays fatal, and semantic
budgets surface as PolicyError.

Assert both halves so a future change that lets another resource recover
here fails this test, rather than Cozy silently telling users the wrong
thing about why their answer is incomplete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`draft_chars` is Python's len() — a count of code points. The relay's
forwarding filter compared it against JavaScript's String.length, which
counts UTF-16 code units. Any non-BMP character in a draft made the two
disagree, so isRlmEvent rejected the frame and relay.ts dropped it before
it ever reached the host descriptor.

Silently, and permanently: the draft keeps the character, so every later
checkpoint of that run was dropped too, and the adapter's high-water mark
had already advanced, so the evidence riding those frames was never
republished. A watchdog SIGKILL then rendered the bare work-log card with
zero salvage — the exact outcome checkpoints exist to prevent.

Cozy is a messaging app. Emoji in a draft is the common case, not an edge
case. Four green suites missed it because every fixture was ASCII, so the
regression tests here use emoji, astral CJK, math alphanumerics, a ZWJ
sequence, and a regional-indicator flag, plus the negative case so a
genuinely wrong draft_chars is still rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine decides whether a terminal extract call is worth making from what
it owns: a retained draft, or a step that ran to completion. Neither exists
when generated code calls a host accessor and then raises before printing —
no draft, no successful step, no stdout — so the engine concludes there is
nothing to extract while the host is holding every row the answer needed.

Observed on a real 314k-message corpus: a run retrieved 84 message GUIDs
across four ledger records and returned a bare BudgetExhausted, having never
attempted extraction at all.

The engine cannot fix this alone without learning what a host data layer is,
which is exactly the boundary this design exists to keep. So it asks instead.
`RLMConfig.extractable_work_probe` is a host-supplied predicate, consulted
only alongside the engine's own test and never replacing it. A probe that
raises is treated as "no work" — a terminal recovery path must not be able to
fail the run it is recovering.

Known follow-up, deliberately not in this commit: unlocking the extract call
is necessary but not sufficient. `_trajectory_summary` builds the extract
prompt from draft + per-iteration code + stdout, so a run whose data exists
only in the host ledger hands the extract pass failing code and nothing else.
Measured: extraction now runs and returns InsufficientEvidence rather than
being skipped. Closing that needs a second, symmetric hook letting the host
contribute opaque context to the extract prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unlocking the terminal extract call was necessary but not sufficient. The
extract prompt is built from the draft plus per-iteration code and stdout —
everything the engine can see — but a REPL only surfaces what generated code
chose to print. Code that fetched rows and then raised prints nothing, so the
extract pass was handed failing code and nothing else and answered
InsufficientEvidence while the rows sat in the host's ledger.

Measured on a real 314k-message corpus: four ledger records and 84 message
GUIDs retrieved, extraction attempted, extraction declined.

`RLMConfig.extract_context_provider` returns pre-rendered text the engine
bounds and concatenates without parsing — the same opacity contract as the
checkpoint payload. Bounded at 8k chars because the extract call is
deliberately uncached (`cache_anchors=None`), so every character is paid at
full prefill and an unbounded host could spend the recovery call's whole
input budget describing what it found. A provider that raises contributes
nothing rather than ending the run it is recovering.

Deliberately narrow: observations are supplied only when the HOST is what
made extraction possible. When the engine can already see the work — a
retained draft, or a step that ran to completion — the prompt stays
byte-identical to what it was before this hook existed. Runs that extract
successfully today keep their answers, their spend, and their benchmark
scores; the behavior change is confined to runs that produce nothing.

This is ReAct's shape rather than a new invention: dspy.ReAct puts every
tool's return value straight into the trajectory, so extraction always sees
the data. dspy.RLM, being REPL-based, has exactly the gap fixed here — its
`variables_info` is built once from the inputs and never reflects what
execution retrieved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shanev
shanev merged commit fa83db8 into main Aug 3, 2026
10 checks passed
@shanev
shanev deleted the wall-clock-extract-fallback branch August 3, 2026 19:36
@shanev shanev mentioned this pull request Aug 3, 2026
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