Commit 141ba1d
Third of the four PRs under [ADR
0005](https://github.com/callstackincubator/simlock/blob/claude/adr-0005-120-docs/docs/adr/0005-gateway-and-worker-modes.md),
closing #118. Stacked on #129.
A gateway stops being a read-only view of the fleet and starts routing
work through it: one fleet-wide queue, a routing policy that picks the
worker, and lease and `device.exec` calls forwarded to whichever worker
owns the device.
## What lands
**New modules under `src/gateway/`**
- `queue.ts` — `FleetQueue`, a thin wrapper over `core/wait-queue.ts`'s
`WaitQueue`. Reused rather than forked; `WaitQueue#list()` was added
upstream because the fleet has to walk the whole FIFO to pass over a
request no worker can serve, where the worker's single-resource model
only ever advances the head.
- `lease-index.ts` — `FleetLeaseIndex`, the gateway's record of the
leases it issued. `rebuildFromWorker` is deliberately upsert-only so
removal has exactly one source of truth (the worker's own
`lease.released`/`lease.expired`), which is what keeps a fresh grant
from racing a stale view refresh.
- `routing.ts` — `RoutingPolicy` plus a registry mirroring
`capacity/strategy.ts`, with one built-in `warm-then-free`: eligibility,
then a warm matching device, then most free capacity (§13).
- `fleet-coordinator.ts` — admission, dispatch and forwarding. All
forwarding goes through one `#forwardToWorker` chokepoint.
- `owner-routed-facts.ts` — replaces the inert placeholder, resolving a
relayed fact's real owner from the index.
**Reshaped:** `dispatcher.ts` (the six lease/exec operations become
real; `lease.list`/`list.get` rewrite gateway-issued lease ids),
`aggregate.ts`, `boundary.test.ts` (core is no longer wholesale
forbidden — four modules are explicitly allowlisted, as that file's own
comment asked a later PR to do), `core/config.ts` (`gateway.routing`),
`contract/schemas.ts`, `daemon/dispatcher.ts`, `daemon/server.ts`,
`daemon/main.ts`.
## Three things worth a reviewer's attention
**The fleet-wide one-lease rule keys on two different fields.**
Admission is `requesterId`-keyed (§14) and runs inside one
`SerializedDecision` together with the enqueue, so two concurrent
requests for one requester cannot both pass before either enqueues.
Ownership authorization for renew/release/exec is `ownerId`-keyed (§26).
§4's proxy pattern means one principal may hold leases under many
requester ids, so conflating them would be wrong in both directions.
**The dispatch race.** Dispatch re-runs on every view change (§11), so a
waiter whose `lease.request` is still in flight to one worker can be
picked up again and sent to another. The `requesterId` admission check
cannot catch this — it runs once, at admission, before either RPC.
`#dispatchTargets` marks a waiter before the RPC and the loop skips
marked waiters, mirroring the worker coordinator's own `#driving` guard.
**Ownership round-trips (§27a).** The gateway forwards the lease's owner
explicitly and the worker stores it, so a rebuilt index authorizes to
the same principal it did before a gateway restart. Without it
`requesterId` survives via the `gw:<instance id>:` prefix but `ownerId`
does not, and `ownsLease` treats an unrecognised lease as authorized —
it would fail open. Only an admin session may set the field; omitting it
keeps the previous behaviour, so the change is additive.
## Testing
`pnpm check` green: typecheck, e2e typecheck, lint, format, unit, and 56
e2e passed / 1 expected fail / 9 skipped.
Nine behaviours were each verified by reintroducing the bug and
confirming the test failed, rather than by inspection. Two of those
tests were rewritten after that check showed they were passing for the
wrong reason — the first dispatch-race and pass-over tests went through
the fast admission path, which never touches the visible queue, so the
race they claimed to exercise could not occur.
## Deviations and follow-ups
- `WorkerDispatchTarget` gained `refresh()`. Without it there is no way
to satisfy §11's "the gateway refreshes that worker's view" after a
stale-view `NO_CAPACITY` short of waiting for the next event or the
periodic tick. `WorkerLink` already had a compatible method, so this is
a pure interface addition.
- Non-admin `lease.list` reads through the index filtered by `ownerId`
rather than scanning raw worker leases against a namespaced principal,
which closes an ownership-collision gap once real fleet leases exist.
- Left for #119: `WORKER_UNREACHABLE` retry and the "dispatched, then
uplink lost" path (`#forwardToWorker` is the seam to wrap);
`lease.release-all` currently throws naming the first unreachable worker
after attempting the rest, because its output has no room for a partial
result; drain lifecycle guarantees and the full reconnect-rebuild e2e.
- **Open, needs a decision:** ADR §15 says an operator must keep a
gateway's `lease.maxTtlMs` at or below every worker's, but nothing
enforces or warns, and `WorkerView` carries no worker TTL cap to check
against. Surfacing it needs a contract and `worker-link` change beyond
this issue's scope.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
---
_Generated by [Claude
Code](https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 323a9dc commit 141ba1d
66 files changed
Lines changed: 6473 additions & 210 deletions
File tree
- docs
- adr
- agent-rules
- src
- bus
- cli
- contract
- core
- cleanup
- daemon
- gateway
- http
- simlock-client
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
| 14 | + | |
14 | 15 | | |
15 | 16 | | |
16 | 17 | | |
17 | 18 | | |
18 | 19 | | |
19 | | - | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
20 | 24 | | |
21 | 25 | | |
22 | 26 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
177 | 177 | | |
178 | 178 | | |
179 | 179 | | |
180 | | - | |
181 | | - | |
182 | | - | |
183 | | - | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
184 | 184 | | |
185 | 185 | | |
186 | 186 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
| |||
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
| 37 | + | |
36 | 38 | | |
37 | 39 | | |
38 | 40 | | |
| |||
61 | 63 | | |
62 | 64 | | |
63 | 65 | | |
64 | | - | |
65 | | - | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
66 | 69 | | |
67 | 70 | | |
68 | 71 | | |
| |||
75 | 78 | | |
76 | 79 | | |
77 | 80 | | |
78 | | - | |
| 81 | + | |
| 82 | + | |
79 | 83 | | |
80 | 84 | | |
81 | 85 | | |
| |||
180 | 184 | | |
181 | 185 | | |
182 | 186 | | |
183 | | - | |
| 187 | + | |
184 | 188 | | |
185 | 189 | | |
186 | 190 | | |
| |||
0 commit comments