Skip to content

feat!(daemon,contract): one TTL-first lease on every transport (ADR 0004, PR B) - #125

Open
V3RON wants to merge 12 commits into
claude/adr-0004-a-client-renewfrom
claude/adr-0004-b-daemon-ttl-only
Open

feat!(daemon,contract): one TTL-first lease on every transport (ADR 0004, PR B)#125
V3RON wants to merge 12 commits into
claude/adr-0004-a-client-renewfrom
claude/adr-0004-b-daemon-ttl-only

Conversation

@V3RON

@V3RON V3RON commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

The daemon-side half of ADR 0004, where the wire breaks. PR A gave the CLI and the MCP session a renew timer over an ordinary lease; this PR removes the mechanism they were shadowing. After it there is one kind of lease — it carries a TTL, a client-initiated lease.renew arriving before the deadline is the only thing that keeps it alive, and nothing about a connection (its close, its daemon's death, a restart) ends one.

Base is claude/adr-0004-a-client-renew, rebased onto its final commit c81c644. Part of #114

What changed, layer by layer

Contract (src/contract/) — ADR 0004 §1/§4, and ADR 0003 §6 for the version rule.
lease.heartbeat leaves the operation registry and the push families, and the heartbeat hello capability goes with it. mode leaves lease.request's input and the lease record; the record instead gains two stored fields, ttlMs (the width it was granted with, or last renewed with) and lastRenewedAt (written at grant and on every renew). lastRenewedAt replaces the dispatcher's derived lastHeartbeatAt, which was computed as ttlDeadline - heldTtlBackstopMs and has no answer once every lease carries its own TTL. ttlMs is accepted on every request now; its upper bound is not expressible in the contract module (it is a daemon config value), so the schema keeps only "a TTL is a positive number". Protocol range is {min: 4, max: 4} with no shim; daemon.stop stays the frozen exception.

Core (src/core/) — ADR 0004 §1/§3/§4.
LeaseLifecycle grants at the request's own width or lease.defaultTtlMs, and a renew naming no ttlMs re-applies the lease's own stored width — never the default, so a four-hour lease does not shrink to fifteen minutes the first time something renews it. LeaseLifecycle.heartbeat (and its detached-lease guard), LeaseEngine.heartbeat, and the release coordinator's port for it are deleted. StartupConverger restores every persisted lease's timer from its own deadline and sweeps nothing: a restart proves nothing about whether a holder is alive. Config is lease.defaultTtlMs (15m) and lease.maxTtlMs (4h), validated together at load (defaultTtlMs <= maxTtlMs, both positive) with a violation failing the daemon start and naming the key.

Daemon (src/daemon/) — ADR 0004 §3/§5.
heldLeaseIds and every piece of bookkeeping around it are gone: the heartbeat push and its timer, release-on-connection-close, and the release-held step in stop(). DispatchSession drops heldLeaseIds/heartbeatCapability. Lease-scoped pushes are untouched (§5) — lease-lost, device-unhealthy and device-recovered still reach every live connection whose principal owns the lease. The only lease-shaped state a connection still keeps is the release it is itself performing, so its own lease-lost push is suppressed for the duration of that call. The ttlMs cap lands in the dispatcher, applied identically to a request and a renew, so every transport shares one answer.

HTTP (src/http/)modeDefaultTtlMs and the tracker's per-lease TTL map are gone. ttlMs on a lease payload is read off the lease record, so a payload served after a daemon restart reports the lease's real width rather than a mode default standing in for a per-request value the gateway used to remember. A body-less POST /v1/leases/{id}/renew re-applies that stored width; a ttlMs above lease.maxTtlMs is 400 BAD_REQUEST on both lease routes (see the review fixes below for where that answer comes from). notices stays HTTP-side and never carries lease_lost.

Frontends — only what the daemon change forces. simlock/client loses the heartbeat option, the lease.heartbeat method, the wire's pong, and the synthesized onLeaseLost on connection loss (ADR 0004 narrows ADR 0003 §10): the leases it held are still granted, so onConnectionLost reports the connection and onLeaseLost only ever reports a lease the daemon actually ended. The CLI stops sending mode, accepts --ttl <duration>, renders lastRenewedAt as "last renewed", and on connection loss writes one DAEMON_CONNECTION_LOST line naming the lease id and its current ttlDeadline, releases nothing, and exits 1. MCP's lease_simulator inherits the contract's optional ttlMs, lease_status no longer filters on mode, and each lease's renew timer reconnects through a new connectToRunningDaemon that reaches an already-listening daemon and never launches one — auto-launch stays a tool-call concern, so an operator's daemon stop is not undone by an idle session.

The wire and config breaks

  • Wire: lease.heartbeat (operation, push, hello capability) and mode (request input, lease record) are gone. ttlMs is accepted on every request and capped at lease.maxTtlMs — above it is BAD_REQUEST, never a silent clamp. The lease record carries ttlMs and lastRenewedAt. Protocol 4, no shim: a protocol-3 client and this daemon do not overlap and hello fails PROTOCOL_VERSION_UNSUPPORTED.
  • Config: lease.detachedTtlMs, lease.heldTtlBackstopMs and lease.heartbeatIntervalMs are retired. All three are simply unrecognized — warned about and ignored like any other unknown key, no alias and no value carried over — while lease.defaultTtlMs and lease.maxTtlMs are new and validated as a pair. The two treatments differ on purpose: a leftover key has a safe reading ("ignore it"), a self-contradicting TTL pair has none, so the latter fails the start.
  • Events (the deliberate 0.x exception to events rule 6, recorded in ADR 0004's Consequences): lease.granted drops mode; lease.released loses the closed and orphaned reasons.
  • Behaviour: a SIGKILLed holder keeps its device until expiresAt, and daemon stop releases nothing. A holder that exits normally, is SIGTERMed, or whose parent dies still releases at once — that is its own policy, and it is now the only thing that frees a device early.

Persisted-record migration

A lease record written before this change has no ttlMs and no lastRenewedAt, and neither is recoverable from what is on disk (ttlDeadline - grantedAt is the grant-time width only until the first renewal moves the deadline). So each takes the documented default rather than a guess dressed up as arithmetic: ttlMs from lease.defaultTtlMs (the configured value — daemon/main.ts passes it to Registry.load), lastRenewedAt from grantedAt. A value that is present but unusable takes the same default: a duration must be finite and positive (a 0 would make every later renewal resolve to a deadline in the past), a timestamp only finite (zero is a legitimate point on the clock). A mode still on disk is dropped on load rather than preserved through the unknown-field forward-compatibility path: it is not a field from a newer schema, it is a concept that no longer exists.

Tests

Rewritten, not skipped or deleted. Nothing was deleted outright — every suite that scripted a heartbeat or asserted on mode now asserts the renew/expiry behaviour that replaced it.

  • Contract/dispatcher: ttlMs accepted on any request, mode rejected, the cap enforced on both a request and a renew (and accepted at the boundary), a body-less renew re-applying the stored width.
  • Config: the pair rule (within a file and across layers), non-positive values naming their key, and each retired key warning while the new keys keep their own defaults.
  • Core: grant/renew widths and lastRenewedAt, startup restoring every timer with no sweep, the migration defaults (including mode dropped on the next write, and unusable ttlMs/lastRenewedAt values falling back), and the registry's renewLease writing all three fields together.
  • DaemonServer: the heartbeat suite became a liveness suite — a lease survives a connection close and ends at its own deadline, a queued waiter is served by expiry rather than by a disconnect, a stop touches nothing, a restart restores the renewed deadline (and expires a lease whose deadline passed while nothing was running), and lease.heartbeat answers UNKNOWN_REQUEST.
  • Frontends: the CLI's connection-loss path (exit 1, one structured line even with a renew in flight, no release attempted) and its reported deadline staying current across renewals; the MCP renew timer reconnecting through connectForRenew and never through the auto-launching connect, with the reconnected client's pushes reaching the session's listeners.
  • e2e: heartbeat-ttl.test.tsrenew-ttl.test.ts, around client renewal, the config pair rule, and the TTL cap. held-lease-liveness.test.ts covers what actually changed: a SIGKILLed holder keeps its device until expiry (short lease.defaultTtlMs, assertions wait for expiry rather than for a disconnect), a SIGTERMed one still releases at once, and a lease survives both an ungraceful and a graceful restart and is renewable afterwards. http-api.test.ts covers the cap on both routes, including the allowDownload: true shape. parent-watch.test.ts runs with a deliberately long TTL, so a device freed within seconds can only be the holder's own release path. The suites that used a SIGKILL as a release were fixed to keep their intent: parallel-contention signals SIGTERM (its point is a holder that goes away the moment it is granted, not the daemon's reaction to a dead socket) and asserts eight requesters were served from no more devices than the cap allows, and capacity-cleanup-nuke releases explicitly before killing. mcp-session's restart test asserts the session keeps its lease and renews it over a connection its own timer built — ADR 0004 §2's second reconnect trigger, end to end.

Review fixes

Round 1 (0fcff93). Blocking: the CLI captured its lease deadline from the grant and never updated it, so the DAEMON_CONNECTION_LOST line named the grant-time deadline however long the holder had been renewing — after roughly one TTL of uptime, a moment in the past on a lease that is perfectly alive, and precisely the number a reader would act on; startLeaseRenewal gained an onRenewed hook and the CLI updates from it. Blocking: LeaseCommands still declared LeaseReleaseReason as "closed" | "explicit" | "killed", drifting from the coordinator's narrowed union and leaving releaseAll's Exclude<…, "closed"> vacuous. Plus: connectForRenew no longer falls back to connect; tests for the three untested properties; server.test.ts waits on the daemon's own "Connection closed" log instead of a sleep; the convergence-window comment now admits a lease-lost push can be missed there and says the client learns via UNKNOWN_LEASE on its next renew; stale comments corrected.

Round 2 (223c551). The one behaviour bug: POST /v1/lease-requests settles its 201 synchronously for an allowDownload: true request, so the dispatcher's cap rejection landed after the response and surfaced as a failed request resource instead of 400. The cap is now answered in the route, before tracker.submit. Plus: the registry migration validates a stored duration as positive and finite (and a timestamp as finite), falling back rather than throwing, as its comment already promised; onRenewed's doc corrected for PR A's newest-answer-wins rule, with both adoption paths routed through one adoptDeadline; four comments describing deleted machinery rewritten; the release-all snapshot skew documented; one shared settle() in the CLI tests; and the e2e restart case given a 30s TTL so a kill-and-restart cannot eat its own window.

Round 3 (f546f22, 2ac05e4, 4cf985d). The renew cadence now comes from the lease's own ttlMs — a duration, which needs no clock shared with the daemon — with ttlDeadline left as the bound that caps each wait; that is the reason ADR 0004 stores the width on the record, and both holders pass it. lease.release-all reports killed rather than explicit: docs/EVENTS.md splits the two by whether the lease's holder asked, and an operator taking every lease away is exactly what a lease-lost reader must tell apart from a holder's own release. The route-level TTL cap was removed from POST /v1/leases/{id}/renew, which awaits its dispatch and so inherits the shared answer after ownsLease — checking it earlier turned another requester's lease into a 400 about a TTL where the socket and docs/HTTP-API.md both say 403; it stays only on the async request route, with unit cover for both of that route's shapes. A simlock lease whose socket dies writes exactly one line: the in-flight lease.renew rejects with the same DAEMON_CONNECTION_LOST and is not a second thing to report. #clientForUse's connect-sharing retry no longer depends on which joined caller's finally ran first, and startLeaseRenewal stays silent about a lease after a holder stopped it from inside onError.

Folded in from PR A's final round, in files this branch now owns: an MCP release_simulator stops its lease's renew timer before sending the release (a renew landing in that window answers UNKNOWN_LEASE and would announce the agent's own release as a lost device, which ADR 0003 §8 forbids) and puts it back only when the lease is still the session's to keep; #endSession's abandoned loop stops with its budget instead of calling releaseLease on a closed client; the grant-after-close release drops its nested timer for the budget it already runs inside; close() documents that with no connection left it releases nothing and reconnects for nothing; and the FakeSimlockClient dead-connection guard is now table-driven over every operation rather than two of them.

Final review (7564ea3). Two from a probe of the merged shape: every caller that shared #connecting reached #adoptClient, so a client connected for one and joined by another was wired twice and stranded the first set of push relays (one push, two notices) — only the first adoption wires it now; and a release that failed transiently put the timer back even when a lease-lost push had ended that lease while the release was in flight, so the restart is now conditional on the lease still being in #renewals. Plus daemon/main.ts's stopAuxiliary comment, which still said a stop releases leases, and one assertion each for three properties the reviewer found unguarded by mutation: the shutdown budget stopping the release loop (not just the wait), the session renewing on the lease's own width, and an embedder supplying only connect still getting the non-launching default for renewals.

Validation

pnpm check (typecheck, e2e typecheck, lint, format check, unit, fake-driver e2e; slow-* excluded) green on the final tree: 82 unit files / 1412 tests, 15 e2e files / 50 tests (1 expected fail, 9 skipped as slow).

Deviations from the docs

One, and it is the docs that are being corrected: docs/CLI.md says a daemon that refuses to boot on a bad config surfaces to an auto-starting command as DAEMON_STARTUP_FAILED, while the launcher actually times out and the CLI reports INTERNAL (it never got a response to relay). e2e/renew-ttl.test.ts asserts the real behaviour; docs corrected in #122. Everything else in docs/CLI.md, docs/CLIENT.md, docs/HTTP-API.md, docs/CONFIGURATION.md, docs/ARCHITECTURE.md, docs/EVENTS.md and docs/known-pitfalls.md on claude/adr-0004-c-docs matches the code, including the exit-code split (1 for a dead connection with the lease standing, 14 for a lease the daemon ended), the retired-key treatment, the cap being a rejection rather than a clamp, and the MCP renew timer reconnecting without ever launching a daemon. No docs are edited here — PR C owns them.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz

@V3RON
V3RON force-pushed the claude/adr-0004-b-daemon-ttl-only branch 2 times, most recently from 223c551 to 4cf985d Compare September 6, 2026 01:15
@V3RON
V3RON force-pushed the claude/adr-0004-a-client-renew branch from c81c644 to 5db801d Compare September 7, 2026 13:05
@V3RON
V3RON force-pushed the claude/adr-0004-b-daemon-ttl-only branch from 7564ea3 to 66f7071 Compare September 7, 2026 13:11
simlock-agent and others added 12 commits September 7, 2026 13:45
ADR 0004 §1/§4 on the contract surface. `lease.heartbeat` leaves the
operation registry and the push families; the `heartbeat` hello capability
goes with it, since nothing declares or answers one any more. `mode` leaves
`lease.request`'s input (a request that still names one is BAD_REQUEST
against the strict object, not a value silently dropped) and the lease
record, which instead gains the two stored fields the record needs to answer
for itself: `ttlMs`, the width it was granted with or last renewed with, and
`lastRenewedAt`, written at grant and on every renew. The latter replaces the
dispatcher's derived `lastHeartbeatAt`, which was `ttlDeadline -
heldTtlBackstopMs` and has no answer once every lease carries its own TTL.

`ttlMs` is now accepted on every `lease.request` rather than being
BAD_REQUEST for a held one. Its upper bound (`lease.maxTtlMs`) is not
expressible here -- it is a daemon config value this module deliberately
cannot see -- so the schema keeps only "a TTL is a positive number" and the
cap is enforced at the dispatcher, where every transport shares it.

The wire moves to protocol 4 with no compatibility shim, so under ADR 0003
§6's honesty rule both ends advertise {min: 4, max: 4}; `daemon.stop` stays
the frozen exception, which is what keeps the upgrade path (stop, then start
the new daemon) available at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
ADR 0004 §1/§3/§4 in the core. `LeaseRecord` loses `mode` and gains `ttlMs`
and `lastRenewedAt`; `LeaseLifecycle` grants at the request's own width (or
`lease.defaultTtlMs`), and a renew with no `ttlMs` re-applies the lease's own
stored width rather than falling back to the default -- a lease granted for
four hours does not shrink to fifteen minutes the first time something renews
it. `LeaseLifecycle.heartbeat` and its detached-lease guard are gone, along
with `LeaseEngine.heartbeat` and the release coordinator's port for it.

`StartupConverger` no longer sweeps: it restores every persisted lease's TTL
timer from its own deadline and releases nothing, because a restart proves
nothing about whether a holder is alive. A lease whose deadline passed while
no daemon was running expires as soon as one is there to expire it, through
the ordinary expiry path.

Config: `lease.defaultTtlMs` (15 minutes) and `lease.maxTtlMs` (4 hours)
replace `lease.detachedTtlMs`, `lease.heldTtlBackstopMs` and
`lease.heartbeatIntervalMs`. The three retired keys are simply unrecognized
-- warned about and ignored like any other unknown key, with no alias and no
value carried over -- while the new pair is validated together at load
(`defaultTtlMs <= maxTtlMs`, both positive) and a violation fails the daemon
start naming the offending key. The two treatments differ on purpose: a
leftover key has a safe reading, a self-contradicting TTL pair has none.

Records written before this change load with `ttlMs` defaulted to
`lease.defaultTtlMs` and `lastRenewedAt` defaulted to `grantedAt`; a `mode`
still on disk is dropped rather than preserved as an unknown field.

Events (a deliberate one-off exception to events rule 6, recorded in ADR
0004's Consequences while the package is 0.x): `lease.granted` drops `mode`,
and `lease.released` loses the `closed` and `orphaned` reasons -- a closing
connection is not a release, and there is no startup sweep to orphan
anything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
ADR 0004 §3 in the transport. `DaemonServer` loses `heldLeaseIds` and every
piece of held-lease bookkeeping around it: the heartbeat push and its timer,
the release-on-connection-close path, and the release-held step in `stop()`.
A connection close is no longer a lease event -- whatever its principal held
is still granted, still counting down, and still renewable by whatever
connects next -- and a `daemon stop` leaves every lease standing with its
deadline for the next daemon to restore a timer from.

Lease-scoped pushes are untouched (ADR 0004 §5): `lease-lost`,
`device-unhealthy` and `device-recovered` still go to every live connection
whose principal owns the lease. What the connection still tracks is one
short-lived set: the release it is itself performing, so its own `lease-lost`
push is suppressed for the duration of that call.

`DispatchSession` drops `heldLeaseIds` and `heartbeatCapability` -- the
operation that read them is gone, and there is no per-connection lease state
left to thread. The dispatcher gains ADR 0004 §4's cap in one place, applied
identically to a request and a renew: a `ttlMs` above `lease.maxTtlMs` is
BAD_REQUEST rather than silently clamped, and because every transport reaches
leases through this one object, HTTP inherits the same answer as 400.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…quest

ADR 0004 on the gateway. `modeDefaultTtlMs` and the tracker's per-lease TTL
map are gone: `ttlMs` on a lease payload is read straight off the lease
record, which the daemon now stores, so a payload served after a daemon
restart reports the lease's real width instead of a mode default standing in
for a value this gateway used to remember. A body-less `POST
/v1/leases/{id}/renew` re-applies that same stored width, and a `ttlMs` above
`lease.maxTtlMs` is 400 BAD_REQUEST from the shared dispatcher rather than
anything restated here.

The request the tracker dispatches no longer names a `mode`; there is one
kind of lease, and HTTP was already granting it. `notices` stays exactly
where it was -- HTTP-side frontend state, not part of the socket contract's
renew response.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
ADR 0004 §2/§3 on the frontends, limited to what the daemon change forces.

`simlock/client` drops the `heartbeat` connect option, the `lease.heartbeat`
method, and the pong the wire used to send. It also stops synthesizing
`onLeaseLost` when a connection dies (ADR 0004 narrows ADR 0003 §10): the
leases it held are still granted and still counting down, so there is nothing
lost to report -- `onConnectionLost` reports the connection, and
`onLeaseLost` now only ever carries a lease the daemon actually ended.

CLI: `simlock lease` sends no `mode`, accepts `--ttl <duration>` for the
lease's initial width, and renders `lastRenewedAt` as "last renewed" in
`status` and `list --leases`. On connection loss the holder writes one
`DAEMON_CONNECTION_LOST` line naming the lease and the `ttlDeadline` a later
invocation has to beat, releases nothing, and exits 1 -- distinct from exit
14, which still means the daemon ended the lease while the connection was
alive.

MCP: `lease_simulator` inherits the contract's optional `ttlMs` (only
`requesterId` is omitted now), `lease_status` matches on the session's own
lease id with no `mode` left to filter on, and the renew timer reconnects
when it fires against a dead client -- through `connectToRunningDaemon`,
which reaches a daemon that is already listening and never launches one, so
an idle session keeps its lease while an operator's `daemon stop` stays
undone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Unit suites follow the contract and core changes: the TTL cap and the
stored-width renew rule at the dispatcher, the config pair rule and the three
retired keys warning without carrying a value over, startup restoring every
timer with no sweep, the persisted-record migration defaults, and `status` /
`list --leases` carrying `lastRenewedAt`. `DaemonServer`'s heartbeat suite
becomes a liveness suite: a lease survives a connection close and ends at its
deadline, a stop touches nothing, a restart restores the renewed deadline,
and `lease.heartbeat` answers UNKNOWN_REQUEST.

e2e: `heartbeat-ttl.test.ts` becomes `renew-ttl.test.ts`, built around client
renewal, the config pair rule, and the TTL cap on both a request and a renew.
`held-lease-liveness.test.ts` covers what ADR 0004 actually changes -- a
SIGKILLed holder keeps its device until expiry, a SIGTERMed one still
releases at once, a lease survives both an ungraceful and a graceful restart
and is renewable afterwards. The suites that used a SIGKILL as a release
(`parallel-contention`, `capacity-cleanup-nuke`) now release explicitly or
signal the holder so its own release path runs, since nothing else frees a
device before the deadline; `mcp-session`'s restart test now asserts the
session keeps its lease and renews it over a connection its timer built.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Review findings on the daemon-side TTL work.

The blocking one: the CLI captured `ourLeaseDeadline` from the grant and never
updated it, so the `DAEMON_CONNECTION_LOST` line named the grant-time deadline
however long the holder had been renewing -- after roughly one TTL of uptime,
a moment in the past on a lease that is perfectly alive, which is exactly the
number a reader would act on. `startLeaseRenewal` gains an `onRenewed` hook,
called from the one place the deadline moves (`adoptDeadline`, which both the
fresh answer and an abandoned request's late one now go through), and the CLI
updates its captured deadline from it.

The other blocking one: `LeaseCommands` in `src/core/lease-ports.ts` still
declared `LeaseReleaseReason` as `"closed" | "explicit" | "killed"`, drifting
from `LeaseReleaseCoordinator`'s narrowed union and leaving `releaseAll`'s
`Exclude<…, "closed">` vacuous. Narrowed to `"explicit" | "killed"`.

Also:

- `McpSession`'s `connectForRenew` no longer falls back to `connect`
  (`src/mcp/main.ts`): an embedder supplying only `connect` would otherwise
  hand the renew timer an auto-launching path, which is the one thing ADR 0004
  §2 says that timer must not have.
- Tests for the three properties that had none: the renew timer reconnecting
  through `connectForRenew` and never `connect`; the CLI's connection-loss
  path (exit 1, the structured line, no release attempted) and its deadline
  staying current across renewals; and the pre-ADR-0004 record migration,
  including `mode` being dropped rather than preserved on the next write.
- `src/daemon/server.test.ts` waits for the daemon's own "Connection closed"
  log rather than sleeping 10ms before asserting that a waiter stays queued.
- `src/daemon/server.ts`: the convergence-window comment claimed no live
  connection can be waiting for a push. It can -- `hello` is answered
  throughout the window -- so the comment now says what actually saves the
  client: its next renew answers `UNKNOWN_LEASE`, which every holder already
  treats as the end of the lease.
- Stale comments: `LeaseLostPush.reason` no longer lists `"closed"` or the
  removed `"daemon-connection-lost"`; the CLI's describe and its "mode still
  goes out" note; a `lease.heldTtlBackstopMs` reference in the lease-policy
  tests and another in its source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Second review round on the daemon-side TTL work.

The one behaviour bug: `POST /v1/lease-requests` is an async resource, and an
`allowDownload: true` request settles its own `201` synchronously rather than
waiting on the dispatch (a driver install can run for minutes before the first
progress callback). So the dispatcher's `lease.maxTtlMs` rejection landed
*after* the response for exactly that shape of request, surfacing as a failed
request resource instead of the `400 BAD_REQUEST` `docs/HTTP-API.md` and ADR
0004 §4 both promise. The cap is now answered in the route, before
`tracker.submit`, through one `requireTtlWithinCap` both lease routes share --
the dispatcher still enforces it for every other transport. e2e covers both
routes and the `allowDownload` case, plus at-cap acceptance and a body-less
renew keeping the lease's stored width.

Also:

- `Registry`'s migration promised that an unusable stored value takes the same
  default an absent one does, then threw `RegistryLoadError` for a wrongly-typed
  one, and accepted a `ttlMs` of `0` or a negative number -- a width that makes
  every later renewal resolve to a deadline in the past. Split into
  `finiteTimestampOr` and `positiveDurationOr`: a timestamp needs only to be
  finite (zero is a legitimate point on the clock), a duration must also be
  positive, and either way an unusable field migrates rather than costing the
  operator the whole registry.
- `onRenewed`'s doc claimed it fires only when the deadline moves. It fires on
  every adopted answer, and since PR A's newest-answer-wins rule that answer may
  be equal to or earlier than the previous deadline. Both adoption paths now go
  through `adoptDeadline`, so the hook and the cadence cannot disagree.
- Comments describing machinery this PR deleted: `settle`'s doc and the
  `stopAuxiliary` sentence quoting it, the startup-convergence note about an
  orphaned lease's reclaim, and the release coordinator's citation of
  `StartupConverger#releaseOrphanedHeldLeases`.
- `lease.release-all` now says what its registry snapshot can and cannot skew:
  a lease expiring inside the window is suppressed, one granted inside it is
  not, and neither can suppress a push for a lease this principal does not own.
- Nits: one shared `settle()` in the CLI tests instead of four inline sleeps,
  and `e2e/held-lease-liveness.test.ts`'s restart case gets a 30s TTL so a
  kill-and-restart cannot eat its own window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
The cadence now comes from `LeaseRecord.ttlMs` -- a duration, so a client whose
clock sits away from the daemon's still renews at the right rate -- with the
deadline left as what it is, the bound that caps each wait and ends the loop.
Both holders pass the width they were granted.

Renewal also stops meaning stopped: `giveUp` is silent (and cancels its timer)
once anything else has stopped the loop, including a `stop()` a holder made from
inside `onError`. And a `simlock lease` whose socket dies writes exactly one
line about it: the in-flight `lease.renew` rejects with the same
`DAEMON_CONNECTION_LOST`, which is not a second thing to report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
A renew dispatched into the window of an in-flight `lease.release` comes back
UNKNOWN_LEASE once the daemon has committed it, and renewal would report the
agent's own release to it as a lost device -- which ADR 0003 §8 says must never
happen. The timer stops before the release is sent and goes back only if the
lease is still the session's to keep: not for UNKNOWN_LEASE or FORBIDDEN, and
not over a dead connection, where reconnecting to keep alive a lease the agent
asked to be rid of is the opposite of what it asked. The lease itself stays
known to the session throughout, so `close()` still owes it a farewell release.

Around it: the connect-power retry no longer depends on which joined caller's
`finally` ran first; `#endSession`'s abandoned loop stops with the budget rather
than calling `releaseLease` on a closed client; the grant-after-close release
drops its nested timer for the budget it already runs inside; and `close()` says
what it does with no connection left (nothing, and it does not reconnect).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…ys killed

`POST /v1/leases/{id}/renew` awaits its dispatch, so the shared cap already
answers it -- and answers it after `ownsLease`, which is what keeps another
requester's lease a 403 rather than a 400 about a TTL that caller could never
have set. The route-level guard stays only on `POST /v1/lease-requests`, whose
`allowDownload: true` shape settles its 201 before a later rejection could
reach it, with unit cover for both shapes.

`lease.release-all` reports `killed`, not `explicit`: docs/EVENTS.md splits the
two by whether the lease's holder asked, and an operator taking every lease away
is the case a `lease-lost` reader has to tell apart from a holder's own release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…s ours

Every caller that shared `#connecting` comes back holding the same client, so
only the first may wire it: a second `#wireClient` stranded the first set of
unsubscribers and delivered every push to the agent twice. And a release that
fails does not put the timer back for a lease the daemon ended while the release
was in flight -- a `lease-lost` push in that window drops it from `#renewals`,
which is now the test for whether there is anything left to renew.

Also: `daemon/main.ts`'s `stopAuxiliary` comment no longer says a stop releases
leases, which it has not since ADR 0004 §3. And one assertion each for three
things that had none: the shutdown budget stopping the release loop rather than
only the wait, the session renewing on the lease's own width, and an embedder
that supplies only `connect` still getting the non-launching default for
renewals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
@V3RON
V3RON force-pushed the claude/adr-0004-a-client-renew branch from 5db801d to 13a1800 Compare September 7, 2026 14:08
@V3RON
V3RON force-pushed the claude/adr-0004-b-daemon-ttl-only branch from 66f7071 to 92b8277 Compare September 7, 2026 14:08
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