Skip to content

ENG-524: Renewable-lease lifecycle + keepalive renew endpoint#3

Merged
clawhavenapp merged 4 commits into
mainfrom
commonzenpython/eng-524-lease-renewal-renewable-lease-lifecycle-keepalive-endpoint
Jul 7, 2026
Merged

ENG-524: Renewable-lease lifecycle + keepalive renew endpoint#3
clawhavenapp merged 4 commits into
mainfrom
commonzenpython/eng-524-lease-renewal-renewable-lease-lifecycle-keepalive-endpoint

Conversation

@czpython

@czpython czpython commented Jul 6, 2026

Copy link
Copy Markdown
Owner

What

Turns the host lease into the core lifecycle primitive (ENG-524): everything is a renewable lease, safe by default, with an explicit permanent escape hatch.

  • New setting LEASE_DEFAULT_TTL (seconds, default 86400). POST /hosts without expires_at now stores now + LEASE_DEFAULT_TTL instead of null. An explicit expires_at: null stays supported as the deliberate opt-in to a permanent, never-reaped host — it is just no longer the accidental default.
  • New endpoint POST /hosts/{id}/renew (service-token auth). Body is either an explicit future tz-aware expires_at (same validators as create, shared in hosts/schemas.py) or empty → extend by LEASE_DEFAULT_TTL from now. Returns the updated host. Only active and bootstrapping hosts renew; unclaimed warm-pool members 409 (pool maintenance owns them); missing host 404.
  • Create path (HostService.get_or_create_host / create_host) distinguishes omitted-vs-explicit-null via an EllipsisType sentinel default; the API route resolves the request shape from model_fields_set.
  • Pool claims now always stamp the caller's resolved lease (default lease, explicit window, or null for a deliberate permanent) onto the claimed host, replacing the warm-pool max-age TTL. Pool warming is unchanged — still POOL_HOST_MAX_AGE_HOURS.
  • Janitor untouched: it already reaps past expires_at.

Why

A long-lived home with expires_at = null was never reaped — if the agent died silently the VM leaked and kept costing money. With renewal as the keepalive, a live agent keeps its home by bumping the window; a dead agent's home lapses and self-reaps. The default TTL protects naive self-hosters from surprise cloud bills.

Acceptance criteria

  • POST /hosts with no expires_at yields a host with expires_at ≈ now + LEASE_DEFAULT_TTL.
  • POST /hosts with explicit expires_at: null yields a permanent host (no expiry).
  • POST /hosts/{id}/renew extends an active host; janitor no longer reaps it.
  • Renew on a pool-unclaimed host → 409; renew on a missing host → 404.
  • Janitor behavior unchanged otherwise (it already reaps past expires_at).

Codex review

  • Round 1 (P2, addressed in ed9f8cd): the janitor snapshots expired ids, then deletes each under its own row lock — a renewal committing in that window returned 200 while the same janitor pass still tore the VM down. delete_host now takes expired_only (janitor-only, mirroring the pool_shed guard): if the locked read shows a live lease again, the host is spared. Pinned by test_janitor_delete_skips_a_host_renewed_in_the_race.
  • Round 2 (P2, addressed in 842f82e): resolving the default lease up front put the full 24h lease on the in-flight provisioning row, so an abandoned provision would leak until the lease instead of reaping after PROVISIONING_GRACE_SECONDS. The omitted-lease sentinel now travels to the points where a lease is actually stamped (pool claim, or the post-provision TTL rewrite); a default create keeps just the safety TTL in flight and the lease starts when the host becomes usable. Pinned by test_create_host_keeps_safety_ttl_while_provisioning.
  • Round 2 (P3, addressed in 842f82e): reap_expired_hosts logged and returned a renewal-race-spared host as reaped. delete_host now returns whether it actually deleted; the janitor reports only real reaps and pool shedding only counts real removals. Pinned by test_reap_does_not_report_a_host_renewed_in_the_race.
  • Round 3 (P2, addressed in d1e103f): the post-provision TTL rewrite stamped the create-time lease unconditionally, discarding a renewal made while the host was still bootstrapping. The rewrite is now a guarded UPDATE on the in-flight safety value, so newer intent wins. Pinned by test_create_host_preserves_renewal_made_during_provisioning.
  • Round 3 (P2, addressed in d1e103f): the new route changes /openapi.json, which api-tests/tests/full-api.spec.js pins exactly. The expected operation list now includes POST /hosts/{host_id}/renew and the black-box suite exercises it against the live created host (auth, extend, 404). The Playwright suite needs a live service + provider credentials, so it was not run locally.
  • Round 4 (re-verification): could not complete — three consecutive runs hung at their first command due to codex runtime issues tonight (task ids review-mr9xdgu0-cqy48d, review-mr9xuxoy-89z4o4, review-mr9yjnqm-d3lyid, all cancelled). Net review state: rounds 1–3 produced 5 findings, all fixed and test-pinned, nothing rebutted.

Notes

  • test_pool_claim_preserves_pool_ttl_when_caller_omits_expires_at pinned the pre-lease behavior (omitted TTL → keep the pool max-age on claim). Its purpose — a claimed host must never leak forever — is now met more strongly by the default lease, so the test was rewritten to the new contract, plus a new test pinning explicit-permanent claims.
  • Verified: uv run ruff check, uv run ruff format --check, uv run pyright (0 errors), uv run pytest (289 passed).

🤖 Generated with Claude Code

czpython and others added 4 commits July 7, 2026 19:17
Every host is now a renewable lease. POST /hosts without expires_at
stores now + LEASE_DEFAULT_TTL (new setting, default 86400s) instead of
null, so an abandoned host lapses and the janitor reaps it; an explicit
expires_at: null stays supported as the deliberate opt-in to a
permanent host. POST /hosts/{id}/renew is the keepalive: it bumps
expires_at to the given future instant, or by LEASE_DEFAULT_TTL from
now on an empty body. Only active and bootstrapping hosts renew;
unclaimed warm-pool members refuse with 409 (pool maintenance owns
them) and a missing host is 404.

Pool claims now always stamp the caller's resolved lease onto the
claimed host — the default lease, an explicit window, or null for a
deliberate permanent — replacing the warm-pool max-age TTL. Warming
itself still uses POOL_HOST_MAX_AGE_HOURS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The janitor snapshots expired host ids, then deletes each one later
under its own row lock — a renewal committing in that window returned
200 while the same janitor pass still tore the VM down. delete_host
now takes expired_only (janitor-only, like pool_shed): if the locked
read shows a live lease again, the host is spared.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two review findings. First, resolving the default lease up front put
the full lease on the in-flight provisioning row, so an abandoned
provision would leak until the lease instead of reaping after
PROVISIONING_GRACE_SECONDS. The omitted-lease sentinel now travels to
the points where a lease is actually stamped — the pool claim, or the
post-provision TTL rewrite — so a default create keeps just the safety
TTL while provisioning and the lease starts when the host is usable.

Second, delete_host now returns whether it actually deleted the row;
the janitor no longer logs or returns a renewal-race-spared host as
reaped, and pool shedding no longer counts a claim-race-spared host as
removed.

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

Two review findings. First, the post-provision TTL rewrite stamped the
create-time lease unconditionally, discarding a renewal made while the
host was still bootstrapping (a client whose POST timed out can find
the row and renew it). The rewrite is now a guarded UPDATE on the
in-flight safety value, so newer intent wins.

Second, the renew route joins the black-box OpenAPI surface:
full-api.spec.js expects POST /hosts/{host_id}/renew and exercises it
against the live created host (auth, extend, 404).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@czpython
czpython force-pushed the commonzenpython/eng-524-lease-renewal-renewable-lease-lifecycle-keepalive-endpoint branch from d1e103f to fe60209 Compare July 7, 2026 17:18
@clawhavenapp
clawhavenapp merged commit b7397ce into main Jul 7, 2026
6 checks passed
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.

2 participants