feat!(daemon,contract): one TTL-first lease on every transport (ADR 0004, PR B) - #125
Open
V3RON wants to merge 12 commits into
Open
feat!(daemon,contract): one TTL-first lease on every transport (ADR 0004, PR B)#125V3RON wants to merge 12 commits into
V3RON wants to merge 12 commits into
Conversation
V3RON
force-pushed
the
claude/adr-0004-b-daemon-ttl-only
branch
2 times, most recently
from
September 6, 2026 01:15
223c551 to
4cf985d
Compare
V3RON
force-pushed
the
claude/adr-0004-a-client-renew
branch
from
September 7, 2026 13:05
c81c644 to
5db801d
Compare
V3RON
force-pushed
the
claude/adr-0004-b-daemon-ttl-only
branch
from
September 7, 2026 13:11
7564ea3 to
66f7071
Compare
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
force-pushed
the
claude/adr-0004-a-client-renew
branch
from
September 7, 2026 14:08
5db801d to
13a1800
Compare
V3RON
force-pushed
the
claude/adr-0004-b-daemon-ttl-only
branch
from
September 7, 2026 14:08
66f7071 to
92b8277
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.renewarriving 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 commitc81c644. Part of #114What changed, layer by layer
Contract (
src/contract/) — ADR 0004 §1/§4, and ADR 0003 §6 for the version rule.lease.heartbeatleaves the operation registry and the push families, and theheartbeathello capability goes with it.modeleaveslease.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) andlastRenewedAt(written at grant and on every renew).lastRenewedAtreplaces the dispatcher's derivedlastHeartbeatAt, which was computed asttlDeadline - heldTtlBackstopMsand has no answer once every lease carries its own TTL.ttlMsis 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.stopstays the frozen exception.Core (
src/core/) — ADR 0004 §1/§3/§4.LeaseLifecyclegrants at the request's own width orlease.defaultTtlMs, and a renew naming nottlMsre-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.StartupConvergerrestores every persisted lease's timer from its own deadline and sweeps nothing: a restart proves nothing about whether a holder is alive. Config islease.defaultTtlMs(15m) andlease.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.heldLeaseIdsand every piece of bookkeeping around it are gone: the heartbeat push and its timer, release-on-connection-close, and the release-held step instop().DispatchSessiondropsheldLeaseIds/heartbeatCapability. Lease-scoped pushes are untouched (§5) —lease-lost,device-unhealthyanddevice-recoveredstill 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 ownlease-lostpush is suppressed for the duration of that call. ThettlMscap lands in the dispatcher, applied identically to a request and a renew, so every transport shares one answer.HTTP (
src/http/) —modeDefaultTtlMsand the tracker's per-lease TTL map are gone.ttlMson 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-lessPOST /v1/leases/{id}/renewre-applies that stored width; attlMsabovelease.maxTtlMsis400 BAD_REQUESTon both lease routes (see the review fixes below for where that answer comes from).noticesstays HTTP-side and never carrieslease_lost.Frontends — only what the daemon change forces.
simlock/clientloses theheartbeatoption, thelease.heartbeatmethod, the wire's pong, and the synthesizedonLeaseLoston connection loss (ADR 0004 narrows ADR 0003 §10): the leases it held are still granted, soonConnectionLostreports the connection andonLeaseLostonly ever reports a lease the daemon actually ended. The CLI stops sendingmode, accepts--ttl <duration>, renderslastRenewedAtas "last renewed", and on connection loss writes oneDAEMON_CONNECTION_LOSTline naming the lease id and its currentttlDeadline, releases nothing, and exits1. MCP'slease_simulatorinherits the contract's optionalttlMs,lease_statusno longer filters onmode, and each lease's renew timer reconnects through a newconnectToRunningDaemonthat reaches an already-listening daemon and never launches one — auto-launch stays a tool-call concern, so an operator'sdaemon stopis not undone by an idle session.The wire and config breaks
lease.heartbeat(operation, push, hello capability) andmode(request input, lease record) are gone.ttlMsis accepted on every request and capped atlease.maxTtlMs— above it isBAD_REQUEST, never a silent clamp. The lease record carriesttlMsandlastRenewedAt. Protocol 4, no shim: a protocol-3 client and this daemon do not overlap andhellofailsPROTOCOL_VERSION_UNSUPPORTED.lease.detachedTtlMs,lease.heldTtlBackstopMsandlease.heartbeatIntervalMsare retired. All three are simply unrecognized — warned about and ignored like any other unknown key, no alias and no value carried over — whilelease.defaultTtlMsandlease.maxTtlMsare 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.lease.granteddropsmode;lease.releasedloses theclosedandorphanedreasons.SIGKILLed holder keeps its device untilexpiresAt, anddaemon stopreleases nothing. A holder that exits normally, isSIGTERMed, 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
ttlMsand nolastRenewedAt, and neither is recoverable from what is on disk (ttlDeadline - grantedAtis 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:ttlMsfromlease.defaultTtlMs(the configured value —daemon/main.tspasses it toRegistry.load),lastRenewedAtfromgrantedAt. A value that is present but unusable takes the same default: a duration must be finite and positive (a0would make every later renewal resolve to a deadline in the past), a timestamp only finite (zero is a legitimate point on the clock). Amodestill 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
modenow asserts the renew/expiry behaviour that replaced it.ttlMsaccepted on any request,moderejected, the cap enforced on both a request and a renew (and accepted at the boundary), a body-less renew re-applying the stored width.lastRenewedAt, startup restoring every timer with no sweep, the migration defaults (includingmodedropped on the next write, and unusablettlMs/lastRenewedAtvalues falling back), and the registry'srenewLeasewriting 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), andlease.heartbeatanswersUNKNOWN_REQUEST.connectForRenewand never through the auto-launchingconnect, with the reconnected client's pushes reaching the session's listeners.heartbeat-ttl.test.ts→renew-ttl.test.ts, around client renewal, the config pair rule, and the TTL cap.held-lease-liveness.test.tscovers what actually changed: aSIGKILLed holder keeps its device until expiry (shortlease.defaultTtlMs, assertions wait for expiry rather than for a disconnect), aSIGTERMed one still releases at once, and a lease survives both an ungraceful and a graceful restart and is renewable afterwards.http-api.test.tscovers the cap on both routes, including theallowDownload: trueshape.parent-watch.test.tsruns with a deliberately long TTL, so a device freed within seconds can only be the holder's own release path. The suites that used aSIGKILLas a release were fixed to keep their intent:parallel-contentionsignalsSIGTERM(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, andcapacity-cleanup-nukereleases 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 theDAEMON_CONNECTION_LOSTline 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;startLeaseRenewalgained anonRenewedhook and the CLI updates from it. Blocking:LeaseCommandsstill declaredLeaseReleaseReasonas"closed" | "explicit" | "killed", drifting from the coordinator's narrowed union and leavingreleaseAll'sExclude<…, "closed">vacuous. Plus:connectForRenewno longer falls back toconnect; tests for the three untested properties;server.test.tswaits on the daemon's own "Connection closed" log instead of a sleep; the convergence-window comment now admits alease-lostpush can be missed there and says the client learns viaUNKNOWN_LEASEon its next renew; stale comments corrected.Round 2 (
223c551). The one behaviour bug:POST /v1/lease-requestssettles its201synchronously for anallowDownload: truerequest, so the dispatcher's cap rejection landed after the response and surfaced as a failed request resource instead of400. The cap is now answered in the route, beforetracker.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 oneadoptDeadline; four comments describing deleted machinery rewritten; therelease-allsnapshot skew documented; one sharedsettle()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 ownttlMs— a duration, which needs no clock shared with the daemon — withttlDeadlineleft 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-allreportskilledrather thanexplicit:docs/EVENTS.mdsplits the two by whether the lease's holder asked, and an operator taking every lease away is exactly what alease-lostreader must tell apart from a holder's own release. The route-level TTL cap was removed fromPOST /v1/leases/{id}/renew, which awaits its dispatch and so inherits the shared answer afterownsLease— checking it earlier turned another requester's lease into a400about a TTL where the socket anddocs/HTTP-API.mdboth say403; it stays only on the async request route, with unit cover for both of that route's shapes. Asimlock leasewhose socket dies writes exactly one line: the in-flightlease.renewrejects with the sameDAEMON_CONNECTION_LOSTand is not a second thing to report.#clientForUse's connect-sharing retry no longer depends on which joined caller'sfinallyran first, andstartLeaseRenewalstays silent about a lease after a holder stopped it from insideonError.Folded in from PR A's final round, in files this branch now owns: an MCP
release_simulatorstops its lease's renew timer before sending the release (a renew landing in that window answersUNKNOWN_LEASEand 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 callingreleaseLeaseon 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 theFakeSimlockClientdead-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#connectingreached#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 alease-lostpush had ended that lease while the release was in flight, so the restart is now conditional on the lease still being in#renewals. Plusdaemon/main.ts'sstopAuxiliarycomment, 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 onlyconnectstill 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 asslow).Deviations from the docs
One, and it is the docs that are being corrected:
docs/CLI.mdsays a daemon that refuses to boot on a bad config surfaces to an auto-starting command asDAEMON_STARTUP_FAILED, while the launcher actually times out and the CLI reportsINTERNAL(it never got a response to relay).e2e/renew-ttl.test.tsasserts the real behaviour; docs corrected in #122. Everything else indocs/CLI.md,docs/CLIENT.md,docs/HTTP-API.md,docs/CONFIGURATION.md,docs/ARCHITECTURE.md,docs/EVENTS.mdanddocs/known-pitfalls.mdonclaude/adr-0004-c-docsmatches the code, including the exit-code split (1for a dead connection with the lease standing,14for 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