Repository navigation
A gateway dispatch is a probe: workers record lease.declined, the gateway records every fleet outcome #431
Description
Part of #329.
Scope
After this PR, every fleet request that ends while its gateway runs has one ending in the gateway's own history, and a worker's history stops calling gateway traffic rejected. A worker records each gateway dispatch it refuses or fails as lease.declined, and names the gateway's request on the lease events of that dispatch. The gateway records request.granted for every grant it settles, and its own lease.rejected for every other ending. Usage figures (#347) then join a fleet request to its outcome by id. ADR 0021 decides this.
In short
Today, when a gateway asks worker A for a device and A has no room, A records lease.rejected (no-wait), and the gateway asks worker B. The history says the request was rejected, although B granted it. When a request fails on a worker, the gateway records nothing of its own. After this PR, A records lease.declined carrying the gateway's request id as fleetRequestId. B's lease.granted carries the same id. The gateway records request.granted for B's grant, and a request that fails anywhere ends in one gateway lease.rejected.
sequenceDiagram
participant G as Gateway
participant A as Worker A
participant B as Worker B
G->>A: lease.request (noWait, fleetRequestId=req_1)
A-->>G: NO_CAPACITY
Note over A: lease.declined {fleetRequestId: req_1} (changed)
G->>B: lease.request (noWait, fleetRequestId=req_1)
B-->>G: grant
Note over B: lease.granted {fleetRequestId: req_1} (changed)
Note over G: request.granted {requestId: req_1, worker: B} (new)
# worker A, `simlock events`, before
lease.requested requester=gw:gw1:alice waitPolicy=no-wait
lease.rejected requester=gw:gw1:alice reason=no-wait
# after
lease.requested requester=gw:gw1:alice waitPolicy=no-wait fleetRequestId=req_1
lease.declined requester=gw:gw1:alice reason=no-wait fleetRequestId=req_1
| Case | Worker today | Worker after | Gateway today | Gateway after |
|---|---|---|---|---|
| probe refused, request retried elsewhere | lease.rejected |
lease.declined |
nothing | nothing (request goes on) |
| probe granted and settled | lease.granted |
lease.granted + fleetRequestId |
nothing | request.granted |
| probe whose provision fails twice | queued (lease.queued) |
lease.declined no-wait, answers NO_CAPACITY |
nothing | after a progress push: lease.rejected worker-failed + code NO_CAPACITY; before: tries another worker |
| last cannot-serve refusal, request never queued | lease.rejected unresolvable-spec |
lease.declined |
nothing | lease.rejected unresolvable-spec |
probe fails after progress, or refused already-leased |
lease.rejected |
lease.declined |
nothing | lease.rejected worker-failed + code + worker |
WORKER_UNREACHABLE, INTERNAL, dispatch timeout |
maybe nothing | maybe lease.declined |
nothing | lease.rejected worker-failed + code + worker |
| gateway stops or crashes with requests open | — | — | nothing | nothing (usage closes them at the next daemon.started, ADR 0021 §5) |
gateway's own timeout/cancelled/no-wait/no-worker |
— | — | lease.rejected |
unchanged |
local simlock lease --no-wait with no room on a worker |
lease.rejected no-wait |
unchanged | — | — |
If this goes wrong, an operator would see a gateway stop granting (a worker refusing the new field), a fleet request with neither request.granted nor lease.rejected on the gateway, or a worker's history still showing rejections for requests the gateway placed elsewhere.
Technical spec
Modules touched
flowchart LR
FC[gateway/fleet-coordinator *] --> C[contract: operations, protocol *]
D[daemon/dispatcher *] --> C
D --> LAC[leasing/lease-acquisition-coordinator *]
LAC --> RB[leasing/lease-request-book *]
LAC --> LL[leasing/lease-lifecycle *]
LS[leasing/lease-startup *] --> RB
LAC --> BUS[bus/index: EventMap *]
FC --> BUS
FC --> MCP[mcp/contracts, http/app: field not offered]
src/contract/operations.ts: thelease.requestinput gainsfleetRequestId?: string, 1 to 200 characters (theidempotencyKeybound). The schema does not tie it tonoWait; the handler does, after its session check, so a caller that may not send a well-formed field getsFORBIDDENwhatevernoWaitsays; input the schema refuses isBAD_REQUESTfirst, as for any request.src/contract/protocol.ts: protocol +1 at merge. The doc comment gains the reason: a worker on the previous version isincompatible.src/mcp/contracts.ts: the lease tool's input omitsfleetRequestId, as it omitsrequesterId.src/http/app.ts: the strict lease body does not declare it, so an HTTP body that carries it is a 400 as for any unknown field.src/gateway/dispatcher.tslease.requesthandler: refusesfleetRequestIdfrom every session withFORBIDDEN(gateways do not chain), beside itsownercheck.src/gateway/worker-link.ts:request.grantedjoins the gateway-own event names a worker's push is refused under.src/daemon/dispatcher.ts#leaseRequest:fleetRequestIdfrom a session that is not the gateway uplink is refused withFORBIDDEN. This is the same check asowner, and the refusal comes before the request is stored. Then afleetRequestIdwithoutnoWait: trueis refused withBAD_REQUEST. The dispatcher passes the field on in the lease request options.- The stored request keeps
fleetRequestIdacross a restart:LeaseRequestRecord(src/core/domain.ts),newLeaseRequestRecord(src/core/lease-request-store.ts),leaseRequestRecordKeysandparseLeaseRequest(src/core/registry.ts), andsrc/leasing/lease-request-book.ts.LeaseRequestOptions(src/leasing/wait-queue.ts, shared with the gateway's queue) carries it to the waiter. src/leasing/lease-acquisition-coordinator.ts: one emit helper decides betweenlease.rejectedandlease.declined, on one condition only: the request carriesfleetRequestId. Every worker rejection site goes through it or applies the same condition:killedandalready-leasedat admission (:239, :258),lease-id-taken(:317),killedfrombeginMaintenance(:336), the waiter's terminal rejection (:1033), the queue timeout insrc/leasing/create-leasing.ts:155(unreachable for a probe, which is never queued, but on the same rule), and startup below.lease.declinedcarriesrequestSpec. A probe is never enqueued:#deferalready rejects anoWaitwaiter, and the second-failure path (:728-735) declines a probe with reasonno-waitand rejects its caller withNoCapacityErrorinstead of#enqueue; a local request still queues there as today.lease.requestedcarries the field.src/leasing/lease-startup.ts:daemon-restartedfor a stored probe islease.declinedwith itsfleetRequestId, through the same helper or the same condition.src/leasing/lease-lifecycle.ts:lease.grantedcarriesfleetRequestIdwhen the grant served a probe.src/bus/index.ts: addslease.declinedandrequest.grantedtoEventMap.fleetRequestId?goes onlease.requestedandlease.granted.lease.rejectedgains the reasonworker-failed,code?andworker?. The field isworker, neverworkerId:payload.workerIdmarks a relayed event (ADR 0014 §6).src/gateway/fleet-coordinator.ts:- Every dispatch sends
fleetRequestId: waiter.id. - Every path that ends a waiter without a grant emits exactly one
lease.rejected:#attempt's terminal branch (:1014) emitsworker-failedwithcodefrom theDispatchErrorit already builds;#rejectUnservablewith refusals emitsunresolvable-specwhether or not the request was queued.
#settleGrantemitsrequest.granted { requestId, worker, leaseId, workerLeaseId }only when the lease index accepted the grant andqueue.resolvesettled the waiter. A grant given back (retry), one the index refused (lease-id-taken, already rejected), and one landing after the waiter was settled (timeout, cancel,dispose()) emit nothing.worker-failedrejections carryworker.dispose()emits nothing (ADR 0021 §4: usage closes those requests at the nextdaemon.started).
- Every dispatch sends
docs/EVENTS.mdanddocs/internal/EVENTS.md: the new event, the new fields and reason, and the emission changes on worker and gateway.
Contract and event changes
lease.requestinput:fleetRequestId?: string, 1 to 200 characters, only withnoWait: true, accepted only on the gateway uplink session. Protocol +1 at merge.- New event
lease.declined { requestId, fleetRequestId, requester, requestSpec, reason };reasonuseslease.rejected's values. Emitter: LeaseAcquisitionCoordinator and lease startup (worker). Both EVENTS.md files. - New event
request.granted { requestId, worker, leaseId, workerLeaseId }. Emitter: FleetLeaseCoordinator (gateway), when the grant is handed to the caller. Both EVENTS.md files. lease.requestedandlease.grantedgain optionalfleetRequestIdon a worker.lease.rejectedgains the reasonworker-failedand optionalcodeandworker. All additive (events rule 6). Both EVENTS.md files.- Emission changes (ADR 0021 also narrows ADR 0014 §6: a worker's events for a probe change; its events for local requests do not):
- a worker emits no
lease.rejectedfor a probe; - a gateway emits
request.grantedfor every grant it settles, andlease.rejectedfor every other ending while it runs; - a worker never queues a probe.
- a worker emits no
Other code on the same state
src/gateway/fleet-coordinator.ts#attemptand#rejectUnservable: the gateway's retry rule is unchanged. The worker no longer mirrors it: it declines every probe refusal, so no second copy of the rule exists (architecture rule 10).src/gateway/queue.ts:rejectreturns whether it settled the waiter. The new gateway emits go through#reject, which emits only whenrejectsettled it, so a waiter settled twice still gives one event.src/gateway/routing/serviceability.ts: reads the gateway's routing verdicts, not events. Unchanged.src/leasing/wait-queue.ts: after this PR no probe enters it (the second-failure path declines a probe instead), so it emits nothing for them. Today one can, after a second failed provision.src/core/warm-pool/converger.ts: triggers onlease.granted. The new field does not change it.FleetLeaseIndexrebuild from a worker's lease list: reads leases, not events. Unchanged.- The usage reader (
usage.getandsimlock stats: the figures from the event history, on a worker and a gateway #347, not merged): its fleet join moves to these ids inusage.getandsimlock stats: the figures from the event history, on a worker and a gateway #347, not here.
Rules in play
architecture.mdrule 10: the decline condition exists once, in the coordinator's emit helper, and startup uses the same condition.events.mdrules 1, 3, 6, 8: the name, post-commit emission, additive fields, both EVENTS.md files in this PR.safety.mdrule 10:fleetRequestIdis bounded, and accepted only from the uplink withnoWait.testing.mdrule 1.
Tests
Worker seam: createLeasing with the fake driver, modelled on src/leasing/lease-acquisition-coordinator.test.ts. Gateway seam: src/gateway/fleet-coordinator.test.ts with its fake workers. Uplink check: src/daemon/dispatcher.test.ts.
- (worker) a probe refused for no capacity emits
lease.declinedwith reasonno-waitand itsfleetRequestId, and nolease.rejected - (worker) a probe naming a model the catalog lacks emits
lease.declinedwith reasonunresolvable-spec - (worker) a probe refused
already-leasedat admission emitslease.declined - (worker) a probe refused
lease-id-takenemitslease.declined - (worker) a probe refused
killedat admission, and one in progress whenbeginMaintenanceruns, each emitlease.declined - (worker) a probe whose boot times out after a progress push emits
lease.declinedwith reasonboot-timeout - (worker) a probe still open when the daemon stops emits
lease.declineddaemon-restartedwith itsfleetRequestIdat the next start - (worker) a local
noWaitrequest with no room still emitslease.rejectedno-waitand nolease.declined - (worker) a probe's
lease.requestedandlease.grantedcarry itsfleetRequestId; a local request's carry none - (worker) the caller of a declined probe gets the same error code as today, except a probe whose provision fails twice, which today is queued and now gets
NO_CAPACITY - (dispatcher)
lease.requestwithfleetRequestIdfrom a session that is not the gateway uplink is refused withFORBIDDENand stores nothing, with and withoutnoWait: true - (dispatcher)
lease.requestwithfleetRequestIdand withoutnoWait: trueis refused withBAD_REQUEST - (gateway) every dispatch sends the gateway's request id as
fleetRequestId - (gateway) a request whose dispatch fails after a progress push emits one
lease.rejectedworker-failedwith the caller's error code - (gateway) a request whose worker is unreachable emits one
lease.rejectedworker-failedwithcodeWORKER_UNREACHABLE - (gateway) a request never queued that ends on a worker's cannot-serve refusal emits one
lease.rejectedunresolvable-spec - (gateway) a request one worker refuses
NO_CAPACITYand another grants emits nolease.rejected - (gateway) a settled grant emits one
request.grantedwithworkerand both lease ids, and noworkerIdin its payload - (gateway) a grant the lease index refuses emits
lease.rejectedlease-id-takenand norequest.granted - (gateway) a grant that lands after
dispose()or after the dispatch timed out emits norequest.granted - (gateway)
lease.requestwithfleetRequestIdsent to a gateway is refused withFORBIDDEN, with and withoutnoWait: true - (gateway)
dispose()with a queued and an in-flight request emits nolease.rejectedand norequest.granted - (gateway) the worker link refuses a
request.grantedpushed by a worker - (gateway) a grant given back for a mismatched
leaseIdemits norequest.granted, and the retried grant emits one - (gateway) a
worker-failedrejection carries the failing worker's id asworker - (gateway) a worker's
ALREADY_LEASEDorLEASE_ID_TAKENbefore any progress push ends the request inlease.rejectedworker-failedwith thatcode, not the gateway's ownlease-id-taken - (worker) a probe whose provision fails twice is declined
no-wait, its caller getsNO_CAPACITY, and it emits nolease.queued; a local request in the same case still queues - (worker) a probe open at a restart is declined
daemon-restartedwith itsfleetRequestIdafter the registry is reloaded from disk - (worker)
lease.declinedfor a probe refused at admission carries itsrequestSpec - (mcp) the lease tool's input schema in
tools/listhas nofleetRequestId - (http) a lease body carrying
fleetRequestIdis answered 400 - (e2e) on a gateway with one worker, a granted request leaves one gateway
request.grantedand a relayedlease.requestedandlease.grantedwhosefleetRequestIdis the gateway's ownlease.requestedid - (e2e) on a gateway with one worker, a gateway request whose chosen
leaseIda local client already holds on the worker ends in one gatewaylease.rejectedworker-failed(codeLEASE_ID_TAKEN) and one relayedlease.declined(lease-id-taken) with the samefleetRequestId, and no relayedlease.rejected
Done when
- On a gateway, a granted request shows one gateway
request.granted, and the relayedlease.grantedcarries the gateway's request id asfleetRequestId. - On a gateway, a request whose chosen
leaseIda local client already holds on the worker shows one gatewaylease.rejectedworker-failedwithcodeLEASE_ID_TAKENandworker, one relayedlease.declinedwith the samefleetRequestId, and no relayedlease.rejected. - A worker that refuses a probe for lack of room records
lease.declined, notlease.rejected(worker test). - A local
simlock lease --no-waitwith no room on a worker still prints and recordslease.rejectedno-wait. - A gateway and a worker one protocol version apart report the worker as
incompatible. - Both
EVENTS.mdfiles listlease.declined,request.granted,fleetRequestId, andworker-failedwithcodeandworker.
Out of scope
- Usage figures and the fleet join (
usage.getandsimlock stats: the figures from the event history, on a worker and a gateway #347 uses this). - A user-facing flag for probes (ADR 0021, alternatives).
Depends on
Approval
- Approved for delivery
Written by an agent.
Activity
[-]A gateway dispatch is a probe: workers record lease.declined and name the fleet request[/-][+]A gateway dispatch is a probe: workers record lease.declined, the gateway records every fleet outcome[/+]github-actions commentedon Oct 9, 2026
Now
task:ready: approved, technical spec present.docs(adr): a gateway dispatch is a probe, and the gateway records eve…
feat(leasing): workers decline probes, the gateway records every flee…
test: a queue timeout declines a request carrying fleetRequestId (#431)
docs: protocol 22 in the architecture overview; test fleetRequestId b…
test: pin the absent fleetRequestId key and the refusal messages; a d…
fix: probe test uses a model the catalog lacks; prose names the gatew…
docs: the #attempt and #classifyLeaseRequestError comments hold for a…
docs(gateway): the lease-request timeout's DispatchError is gateway-r…