Files
ironclaw/scripts
firat.sertgoz 307521f155 fix(processes): lease expiry recovers safe runs instead of failing them; isolate the journal heartbeat pool (#7471)
* fix(processes): resume runs whose lease expired at a safe checkpoint

A hosted run that lost its lease died as a user-visible failure, even when
it had committed nothing and was sitting idle waiting on the model. Lease
recovery could only tell "has a checkpoint" from "has none", so every
checkpointed run was treated as possibly-mid-side-effect and failed
terminally with `lease_expired`.

Record what the checkpoint actually was. `ProcessCheckpointKind`
(`BeforeModel` / `BeforeSideEffect` / `BeforeBlock`) now rides on the
process snapshot beside `checkpoint_ref`, because a recovery sweep reads
process rows without loading checkpoint rows. Recovery requeues a run whose
latest checkpoint replays no external effect, under the same bounded
crash-reclaim budget; `BeforeSideEffect` and unknown kinds stay terminal,
since no durable idempotency exists for a replayed capability call.

The requeue waits one full lease TTL past expiry before acting. A worker
starved of heartbeats and a dead worker look identical from the journal;
a worker still running would have renewed its lease inside that window, so
anything still expired afterwards is genuinely gone. Nothing else changes
timing: cancellation and the checkpointless requeue stay immediate.

Old journals deserialize with no kind, and an unrecognized kind degrades to
"unknown" rather than failing the whole snapshot — both read as
side-effecting, so the fail-closed path is the default.

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

* fix(composition): give the process journal its own Postgres pool

The journal heartbeat is the liveness signal a run's lease depends on, and
it was sharing one max-size-2 connection pool with every other Postgres
consumer — the event store, triggers, result reads. One turn's read burst
could saturate that pool and starve the run's own heartbeat until the lease
expired underneath it.

Two changes, both about not putting the heartbeat behind other traffic:

- A Postgres deployment opens a small second pool (2 connections) and
  mounts the journal's filesystem over it. The mount set is byte-identical
  to the data plane's, so the journal addresses the same rows over a
  different connection — only the pool differs. libSQL and in-memory arms
  are untouched; libSQL is single-writer by design.
- The default data-plane pool goes 2 -> 8. Two was small enough that one
  turn queued behind itself.

Operators sizing connections should budget `pool_max_size + 2` per
instance; docs, the shipped Docker config, and its smoke assertion follow.

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

* fix(turn_runner): let a turn survive one slow store checkout

Hosted turn runs heartbeat every 5 seconds, and the supervisor uses that
interval as each heartbeat's timeout, so the generic budget of 3
consecutive failures tolerated about 30 seconds of stall — no more than a
single Postgres connection-checkout timeout. One slow checkout was enough
to abandon a healthy run.

Turn runs now get 8, which clears a checkout stall while still giving up
inside the 90-second lease TTL. That bound is the point: a worker that
stops on its own leaves a live lease behind, whereas one still retrying
past the TTL gets its run reclaimed out from under it. The budget is
therefore derived from the configured heartbeat interval rather than fixed,
so widening the interval shrinks the budget instead of producing an abandon
window that outlives the lease.

The generic `ProcessSupervisorConfig` default stays at 3 — the capability
path heartbeats every 30 seconds, where 8 would run far past the TTL.

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

* fix(ci): route the shipped Docker configs to the test that pins them

`Detect Reborn test scope` failed closed on
`docker/reborn/config.production.toml`, skipping every downstream Reborn
lane. The planner's comment claimed the runtime configs have no owning
lane, but `crates/app/ironclaw_cli/tests/smoke.rs` parses both
`config.toml` and `config.production.toml` and pins their boot profile,
storage backend, pool sizing and runtime policy — so a lane does read
them, and static control would have skipped exactly the assertions such
an edit can break.

Add `ROOT_FIXTURE_TEST_OWNERS`, the read-at-test-time counterpart of
`EMBEDDED_ASSET_OWNERS`, mapping each config to the `smoke` test target
that asserts it. The two hosted-single-tenant configs stay unclassified:
no test parses them, so they must keep failing closed.

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

* test(processes): pin the serde and grace-window edges review flagged

Three review findings on the recovery path, none behavioural:

- `lease_duration_millis` now converts the TTL once instead of three
  verbatim copies in claim, heartbeat and expiry recovery, so the bound
  and its rejection message cannot drift between them.
- The in-grace sweep now runs strictly after expiry. Expiry is inclusive
  (`lease_expires_at <= now`), so the old instant did select the process
  and the assertion was not vacuous — but pinning the grace hold on a
  boundary instant made that a property of the comparison rather than of
  the grace window.
- A persisted snapshot carrying an unrecognized `checkpoint_kind`, or
  none at all, now has coverage at the serde boundary: it degrades to
  `None` rather than failing the whole read, and `None` already recovers
  as side-effecting, so an older host fails closed.

The retry-projection fixture also derives `kind` the way
`put_loop_checkpoint` does instead of claiming `BeforeModel` in metadata
while storing `None`, and now asserts the retried snapshot inherits the
kind — the propagation was previously unasserted.

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

* fix(turn_runner): cap the heartbeat interval the lease cannot afford

Both reviewers found the same hole. `heartbeat_failure_budget_within_lease`
computed how many failures the lease TTL could pay for and then clamped
the result up to 1 — but at an interval past half the TTL, even one
failure costs more than the lease. An operator setting
`heartbeat_interval_secs = 120` against the 90s default got a budget of
1 whose abandon window was 240s: the journal would declare the lease
expired while the worker was still waiting on its first heartbeat, which
is exactly the "worker is provably gone" premise recovery relies on.

Cap the interval at half the TTL instead of clamping the budget, so
`budget >= 1` is honest for every configurable value and the scheduler
errs toward heartbeating more often than asked. The explicit budget
setter is capped by the same lease-derived ceiling — it was the other
way to construct a config past the TTL.

The test's `|| budget == 1` escape hatch was the hole itself; it is gone,
and the 120s case plus a 10x-TTL case are now asserted for real.

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

* test(composition): prove the journal pool reaches the data plane's rows

The only coverage of the journal's separate Postgres pool built two
independent `InMemoryBackend`s and wrote through each, which cannot
prove the two pool-backed handles reach the same rows — a wrong
connection config or a shared-pool fallback passed it unchanged.

Drive `postgres_from_config_and_env` (the only public constructor that
resolves a connection config, and so the only one that opens the second
pool at all), submit a turn through the turn coordinator so the journal
writes its process row over its own pool, and read that row back over a
connection neither build pool owns. Docker-gated through the existing
`postgres_pool_or_skip` harness; the in-memory test stays as the cheap
mount-set parity check.

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

* docs(contracts): describe the expired-lease machine that ships

Both contracts said an expired lease transitions to `RecoveryRequired`.
Recovery converges directly to a settled state instead: cancellation to
`Cancelled`; no checkpoint or a replay-safe one (`BeforeModel`,
`BeforeBlock`) back to `Queued`, the checkpointed case only after a full
lease TTL of grace; a side-effecting or unrecognized checkpoint, or an
exhausted reclaim budget, to `Failed`. Code is the deliberately-reviewed
behavior here, so the docs follow it.

`turn-runner.md` §3 carries the full transition table and notes that the
legacy `RecoveryRequired` status still exists in the vocabulary but is no
longer produced by expiry; `turn-persistence.md` §6 gets the summary and
points at it, so the two cannot drift into two half-descriptions again.

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

* ci(planner): classify the shipped docker/reborn configs by their parsing test

`Detect Reborn test scope` failed closed on
`docker/reborn/config.production.toml` (`unclassified pull-request path`),
cascading into the whole `Tests (Reborn)` roll-up on #7471.

The planner's comment asserted these configs have no owning lane. They do:
`crates/app/ironclaw_cli/tests/smoke.rs` parses both `config.toml` and
`config.production.toml` through `RebornConfigFile::parse_text` and asserts on
the profile, storage backend and policy. So they are not static control (whose
membership rule is "no Reborn test lane reads the file") and not prose —
either would silently under-select the one lane that catches a broken
production config. `DOCKER_RUNTIME_CONFIG_OWNERS` routes each to that test
target instead.

The two `config.hosted-single-tenant*.toml` siblings stay fail-closed: their
reader is `tests/dockerfile_runtime_home.rs`, which `_root_test_partitions()`
does not inventory, so no lane can be selected for them.

Verified against #7471's real diff: the planner exited 1 before and exits 0
after, naming the owner in its reasons; all 75 planner self-tests pass.

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

* fix(processes): fence stale executors on lease reclaim; address review comments

- supervisor: never start a replacement executor while the reclaimed
  process's prior executor is still running; a definitive lease-lost
  heartbeat (InvalidLease/InvalidTransition) now cancels the stale task
  at the next tick instead of waiting out the failure budget
- turn scheduler: clamp heartbeat intervals past half the lease TTL
  (budget-of-one no longer masks an unaffordable interval); CLI config
  rejects them with a clear error
- extract lease_duration_millis helper shared by claim/heartbeat/recovery
- tests: fence regression (no executor overlap), unknown checkpoint-kind
  wire degradation, in-grace sweep strictly past expiry, retry kind
  propagation, stale-worker reply assertions, Postgres pool isolation,
  planner owner pin, smoke test for the interval bound
- docs: turn-runner expired-lease contract aligned with the state machine

* chore(deps): bump lru 0.18.1 -> 0.18.2 (RUSTSEC-2026-0253)

cargo-deny fails fast-checks on the lru 0.18.1 panic-safety advisory
(use-after-free in LruCache::pop, patched in 0.18.2, issued 2026-08-11).
All four dependents (composition, webui, extension_host, hooks) already
require `lru = "0.18"`, so this is a lock-only patch bump.

* fix(loop): lease-fence transcript writes so a reclaimed worker cannot ghost-reply

Lease recovery requeuing a safe checkpoint opened a window the journal alone
cannot close: run transitions are lease-fenced (ensure_lease), but transcript
writes were not, so a worker whose lease recovery already reclaimed — starved
of heartbeats while blocked in a model call, or suspended past the grace
window — could wake and append a second assistant answer beside the
replacement worker's. Time-based fencing (abandon window + one TTL of grace)
bounds only a worker whose runtime is live to observe its heartbeat failures;
it can never be total.

The other half of the guarantee: ThreadBackedLoopTranscriptPort now carries
the lease the run was claimed under and asks the journal — the only authority
on ownership — before every transcript write (draft begin/update, finalize,
capability-result append). A stale or unverifiable lease refuses the write as
an explicit transcript-write failure; the zombie's loop exit then fails
through its own lease-fenced claim, so nothing it produces can land on the
run the replacement completed. The turn runner's host factory installs the
fence for every claimed run.

The recovery branch comment in the process journal now states the two-part
guarantee honestly instead of over-claiming that the old executor has
"provably given up".

Regression coverage: the lease_wedge integration test releases the stale
worker after the recovered run completes and asserts its output never reaches
the transcript; a seam test pins that the production host build installs the
fence at all.

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

* fix(processes): drop duplicated lease_duration_millis from the merge

Both reconciled lines extracted the same helper; the merge kept both
copies and E0592'd. One definition remains, with the fuller doc.

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

* fix(runtime): address lease recovery review feedback (#7471)

* ci: reseed composition budget for merged tree (#7471)

* docs(turns): clarify legacy recovery lock release (#7471)

* fix(architecture-tests): move the absolute-mass record with the reseeded ceiling

The 2026-08-11 budget reseed (41582 -> 41810 for #7471's merged tree)
updated the manifest ceiling but not the paired record constant this
ratchet compares it against, leaving 228 LOC of apparent headroom — past
the 200-LOC nudge window, so the merge-queue run failed
reborn_restructure_baseline_ratchets_stay_armed.

Re-measured on this tree: 41731 (`check-composition-budget.sh`). The
41810 ceiling was seeded from the merge-queue commit, where concurrent
mainline growth adds ~79 LOC on top of this branch — inside the nudge
window of the corrected record.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 21:54:34 +00:00
..