@@ -36,8 +36,9 @@ import type { DaemonServer } from "./server.js";
3636
3737const GATEWAY_PORT = 48173 ;
3838const GATEWAY_URL = `ws://127.0.0.1:${ GATEWAY_PORT } ` ;
39- /** Distinct from `GATEWAY_PORT` above so this file's second suite never races the first test's
40- * own listener through TIME_WAIT on the same port. */
39+ /** Distinct from `GATEWAY_PORT` above so this file's second `it` never races the first one's
40+ * own listener through TIME_WAIT on the same port (H3, round 1 review: both are `it`s in this
41+ * one `describe`, not two separate suites). */
4142const RESTART_GATEWAY_PORT = 48174 ;
4243const RESTART_GATEWAY_URL = `ws://127.0.0.1:${ RESTART_GATEWAY_PORT } ` ;
4344
@@ -54,6 +55,33 @@ function agentSession(overrides: Partial<DispatchSession> = {}): DispatchSession
5455 } ;
5556}
5657
58+ /**
59+ * C3 (round 1 review, #119): `connection === "connected"` alone does not mean a worker's view is
60+ * usable. `WorkerLink#start` (`worker-link.ts`) calls `registry.connected(...)` *before*
61+ * `events.subscribe` and before the first `refresh({ includeCatalog: true })` lands, so there is
62+ * a real window in which a worker reports `connected` while its view still carries an empty
63+ * `catalog` and an `undefined` `capacity` -- both of which make `routing.ts#isEligible` drop it.
64+ * Reproduced 7/10 under load (8 `yes` processes on a 4-core box). Gate on the view actually
65+ * being able to serve `model`, not merely on the connection flag, so a `lease.request` right
66+ * after this wait never races that window into a spurious `No worker in the fleet can currently
67+ * serve this request`.
68+ */
69+ function workerServes (
70+ workers : readonly {
71+ readonly connection : string ;
72+ readonly capacity ?: unknown ;
73+ readonly catalog : readonly { readonly models : readonly string [ ] } [ ] ;
74+ } [ ] ,
75+ model : string ,
76+ ) : boolean {
77+ return workers . some (
78+ ( worker ) =>
79+ worker . connection === "connected" &&
80+ worker . capacity !== undefined &&
81+ worker . catalog . some ( ( entry ) => entry . models . includes ( model ) ) ,
82+ ) ;
83+ }
84+
5785describe ( "gateway fleet smoke (ADR 0005 §35)" , ( ) => {
5886 const daemons : DaemonServer [ ] = [ ] ;
5987 const directories : string [ ] = [ ] ;
@@ -120,9 +148,20 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
120148 readonly token : string ;
121149 readonly stdout : string ;
122150 readonly gatewayUrl ?: string ;
123- } ) : Promise < DaemonServer > {
124- const directory = await mkdtemp ( join ( tmpdir ( ) , `simlock-e2e-${ options . label } -` ) ) ;
125- directories . push ( directory ) ;
151+ /**
152+ * C1 (round 1 review, #119): reuse an already-`mkdtemp`'d directory and its `Filesystem`
153+ * instead of minting fresh ones -- what restarting *this same worker machine* after an
154+ * outage needs, so its `instance.json` (and therefore the `workerId` the gateway already
155+ * knows) survives the restart, exactly as `startRestartableGateway` reuses the gateway's own
156+ * directory and filesystem below. Omit for a worker that starts once and is never restarted.
157+ */
158+ readonly existing ?: { readonly filesystem : Filesystem ; readonly directory : string } ;
159+ } ) : Promise < { daemon : DaemonServer ; filesystem : Filesystem ; directory : string } > {
160+ const directory =
161+ options . existing ?. directory ??
162+ ( await mkdtemp ( join ( tmpdir ( ) , `simlock-e2e-${ options . label } -` ) ) ) ;
163+ if ( options . existing === undefined ) directories . push ( directory ) ;
164+ const filesystem = options . existing ?. filesystem ?? new MemoryFilesystem ( ) ;
126165 const daemon = await startDaemon ( {
127166 configOverrides : {
128167 gateway : {
@@ -152,13 +191,13 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
152191 platform : "android" ,
153192 } ) ,
154193 ] ,
155- filesystem : new MemoryFilesystem ( ) ,
194+ filesystem,
156195 logger : new NoopLogger ( ) ,
157196 statePath : join ( directory , "state.json" ) ,
158197 version : "1.0.0-e2e" ,
159198 } ) ;
160199 daemons . push ( daemon ) ;
161- return daemon ;
200+ return { daemon, directory , filesystem } ;
162201 }
163202
164203 it ( "leases a device on the right worker and execs a real command against it through the gateway" , async ( ) => {
@@ -189,10 +228,11 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
189228
190229 // Both uplinks dial on their own schedule outside `startDaemon`'s own returned promise
191230 // (`main.ts` fires `gatewayUplink.start()` without awaiting it) -- wait for the gateway to
192- // actually see both before leasing.
231+ // actually see both usably (C3, round 1 review) before leasing, not merely `connected` .
193232 await vi . waitFor ( async ( ) => {
194233 const { workers } = await gateway . dispatch ( "worker.list" , { } , adminSession ( ) ) ;
195- expect ( workers . filter ( ( worker ) => worker . connection === "connected" ) ) . toHaveLength ( 2 ) ;
234+ expect ( workerServes ( workers , "Pixel-A" ) ) . toBe ( true ) ;
235+ expect ( workerServes ( workers , "Pixel-B" ) ) . toBe ( true ) ;
196236 } ) ;
197237
198238 const grant = await gateway . dispatch (
@@ -263,19 +303,37 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
263303 gatewayUrl : RESTART_GATEWAY_URL ,
264304 } ) ;
265305
306+ // C3 (round 1 review, #119): gate on both views actually being usable, not merely
307+ // `connected` -- see `workerServes`'s own doc comment. This also doubles as the second-order
308+ // fix the review calls for: it is a positive assertion, *before* draining, that worker-b's
309+ // view already carries "Pixel-B" -- so the later `NO_CAPACITY` below can only mean the drain
310+ // excluded it, never that its catalog just had not landed yet.
266311 await vi . waitFor ( async ( ) => {
267312 const { workers } = await firstGateway . dispatch ( "worker.list" , { } , adminSession ( ) ) ;
268- expect ( workers . filter ( ( worker ) => worker . connection === "connected" ) ) . toHaveLength ( 2 ) ;
313+ expect ( workerServes ( workers , "Pixel-A" ) ) . toBe ( true ) ;
314+ expect ( workerServes ( workers , "Pixel-B" ) ) . toBe ( true ) ;
269315 } ) ;
270316 const beforeDrain = ( await firstGateway . dispatch ( "worker.list" , { } , adminSession ( ) ) ) . workers ;
271317 const workerBId = beforeDrain . find ( ( worker ) => worker . label === "worker-b" ) ?. id ;
272318 if ( workerBId === undefined ) throw new Error ( "worker-b never connected" ) ;
273319
274320 // Lease worker-a's own device -- the fleet client below must still be able to renew this
275321 // exact lease after everything that follows.
322+ // P2 (round 1 review, #119): `requesterId` set explicitly, distinct from the session
323+ // principal -- `agentSession({ principal: "fleet-owner" })` alone makes `ownerId` and
324+ // `requesterId` the same string (`dispatcher.ts` defaults both to `session.principal`), so
325+ // the §27a assertion below (owner authorizes, requester does not) could not tell the two
326+ // fields apart: mutating `lease-index.ts` to re-derive `ownerId` from `requesterId` on
327+ // rebuild would still pass. With a distinct `requesterId`, only a genuinely separate
328+ // `ownerId` on the rebuilt entry can authorize the renew below.
276329 const grant = await firstGateway . dispatch (
277330 "lease.request" ,
278- { model : "Pixel-A" , platform : "android" , noWait : true } ,
331+ {
332+ model : "Pixel-A" ,
333+ platform : "android" ,
334+ noWait : true ,
335+ requesterId : "fleet-owner-session-1" ,
336+ } ,
279337 agentSession ( { principal : "fleet-owner" } ) ,
280338 ) ;
281339 expect ( grant . lease . worker ?. label ) . toBe ( "worker-a" ) ;
@@ -287,8 +345,10 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
287345 const { workers } = await firstGateway . dispatch ( "worker.list" , { } , adminSession ( ) ) ;
288346 expect ( workers . find ( ( worker ) => worker . id === workerBId ) ?. drained ) . toBe ( true ) ;
289347 } ) ;
290- // No new dispatch reaches a drained worker: a request only worker-b could serve queues
291- // rather than landing on it -- proven over the real uplink, not a scripted directory.
348+ // No new dispatch reaches a drained worker: a `noWait: true` request only worker-b could
349+ // serve is rejected outright (H3, round 1 review: it does not queue -- with worker-b the
350+ // only worker for "Pixel-B" now excluded, there is no eligible worker at all) rather than
351+ // landing on worker-b -- proven over the real uplink, not a scripted directory.
292352 await expect (
293353 firstGateway . dispatch (
294354 "lease.request" ,
@@ -298,8 +358,8 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
298358 ) . rejects . toMatchObject ( { code : "NO_CAPACITY" } ) ;
299359
300360 // Kill worker-b outright: an operator taking a drained machine down for maintenance.
301- await workerB . stop ( "test" ) ;
302- daemons . splice ( daemons . indexOf ( workerB ) , 1 ) ;
361+ await workerB . daemon . stop ( "test" ) ;
362+ daemons . splice ( daemons . indexOf ( workerB . daemon ) , 1 ) ;
303363
304364 // Restart the gateway. Everything it held only in memory -- worker views, the lease index,
305365 // the fleet queue -- is gone (ADR §30); only what it persisted survives, because this reuses
@@ -334,6 +394,37 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
334394 { timeout : 15_000 } ,
335395 ) ;
336396
397+ // C1 (round 1 review, #119): this is the assertion this test's own header comment promises
398+ // and never made -- that `workers.json` actually survived the restart. A drained-but-absent
399+ // worker has no `WorkerView` at all (a view is only ever built by `connected()`/`refresh()`,
400+ // never synthesized from the persisted drain set on its own), so the only way to observe the
401+ // restarted gateway's own on-disk state through the same `worker.list` the rest of this test
402+ // uses is a worker bearing `workerBId` actually reconnecting to it -- "worker-b's machine
403+ // comes back up after maintenance" reusing the *same* directory and `Filesystem` worker-b
404+ // had before (so its `instance.json`, and therefore its `workerId`, survives too), exactly as
405+ // `restartedGateway` reuses the gateway's own. Its view is built from nothing but this fresh
406+ // process's own `connected()` call plus whatever `restartedGateway` loaded from
407+ // `workers.json` at startup -- there is no other way `drained` could come back `true` here.
408+ const restartedWorkerB = await startWorker ( {
409+ label : "worker-b" ,
410+ model : "Pixel-B" ,
411+ token : tokenB ,
412+ stdout : "hello-from-b" ,
413+ gatewayUrl : RESTART_GATEWAY_URL ,
414+ existing : { directory : workerB . directory , filesystem : workerB . filesystem } ,
415+ } ) ;
416+ await vi . waitFor (
417+ async ( ) => {
418+ const { workers } = await restartedGateway . dispatch ( "worker.list" , { } , adminSession ( ) ) ;
419+ const view = workers . find ( ( worker ) => worker . id === workerBId ) ;
420+ expect ( view ?. connection ) . toBe ( "connected" ) ;
421+ expect ( view ?. drained ) . toBe ( true ) ;
422+ } ,
423+ { timeout : 15_000 } ,
424+ ) ;
425+ await restartedWorkerB . daemon . stop ( "test" ) ;
426+ daemons . splice ( daemons . indexOf ( restartedWorkerB . daemon ) , 1 ) ;
427+
337428 // The lease survives the restart (ADR §30) and renewing resumes for its original requester
338429 // once the reconnect rebuild has run -- retried because the rebuild races the reconnect
339430 // itself becoming visible above.
@@ -364,5 +455,5 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
364455 { leaseId : grant . lease . id } ,
365456 agentSession ( { principal : "fleet-owner" } ) ,
366457 ) ;
367- } , 40_000 ) ;
458+ } , 55_000 ) ;
368459} ) ;
0 commit comments