Skip to content

Commit 8c4bf06

Browse files
claudeV3RON
authored andcommitted
feat(gateway): warn when a worker joins with a lower lease.maxTtlMs
ADR 0005 §15 tells the operator to keep a gateway's lease.maxTtlMs at or below every worker's, because a fleet lease's width is decided at the gateway (admission) but dispatched as an ordinary lease.request a lower-capped worker still refuses. Nothing enforced or surfaced that mismatch: an operator who got it wrong only saw gateway-accepted requests fail on some machines and not others, with the cause sitting unreported at the exact point (a worker's view) where it was already visible. The user decided: warn, don't clamp. Clamping the gateway's cap to the minimum of its workers' would contradict "a fleet lease's width is decided at the gateway" and make fleet policy drift as machines connect and disconnect. What changed: - workerViewSchema gains an optional `lease: { maxTtlMs }` (schemas.ts), projected in WorkerLink#rebuildView alongside the existing `downloads.policy` -- the same already-fetched config.get payload, no new round trip. - WorkerRegistry#refresh compares an incoming worker's lease.maxTtlMs against the gateway's own (threaded in as `leaseMaxTtlMs`, from GatewayService down to config.lease.maxTtlMs in daemon/main.ts) and logs a warning naming the worker (id + label), both values, and what it means. No bus event: this is an operator configuration warning, not a business fact about the fleet (docs/agent-rules/events.md), matching the precedent in core/config.ts's worker-only-key warning. Where the transition is detected, and why: inside WorkerRegistry#refresh, by comparing the incoming lease.maxTtlMs against the *previous* view's own value rather than tracking separate state. config.get (and so lease.maxTtlMs) is re-read on every periodic backstop tick alongside the catalog, not only at connect (GatewayService#runTick calls link.refresh({ includeCatalog: true }) for every link) -- so "only warn once per connect" would have been wrong. Comparing against the previous value gets the right behavior for free: a worker's first refresh after connecting has no previous lease.maxTtlMs to match, so it warns the moment a low cap is first reported; an unchanged later refresh finds the same value already recorded and says nothing new; a cap that drops further warns again, since that is itself a new fact. Tests (worker-registry.test.ts, service.test.ts at both the registry-unit and full GatewayService-integration levels): a worker below the gateway's cap warns; one at or above it does not; one reporting no cap at all (config === undefined, the same condition that already leaves downloads.policy unset) neither warns nor throws; an unchanged refresh does not repeat the warning; a cap dropping further while already below warns again. Reverted the registry/service/schema/link changes and re-ran: all six new tests fail with named assertions (`expected [] to have a length of 1`, `expected undefined to be defined`), never a bare timeout. Docs updated as part of this same change (the ADR is the specification here): ADR 0005 §15 gets the warn-not-clamp sentence, and §7's "exactly one field" becomes "two fields" now that lease.maxTtlMs travels the same path as downloads.policy; docs/CONFIGURATION.md's lease.maxTtlMs section gets a paragraph on the new warning. docs/adr/0005-gateway-and-worker-modes.md has not been merged to main -- amending it here revises an unmerged record in place, not an accepted decision. No known-pitfalls.md entry: this is a decided, warn-only design (not an accepted gap with a planned fix), so there is nothing pending to record there. pnpm check is green (typecheck, typecheck:e2e, lint, format:check, unit tests, e2e tests). Protocol range unchanged ({ min: 5, max: 5 }) -- this is an additive schema field, no wire renegotiation involved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
1 parent fc0be58 commit 8c4bf06

11 files changed

Lines changed: 299 additions & 13 deletions

docs/CONFIGURATION.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,15 @@ worker's**, or requests that the gateway happily accepts will fail on
198198
whichever machine they land on, which is the least debuggable version of this
199199
mistake.
200200

201+
The gateway does not enforce this for you — clamping its own cap to the
202+
minimum of its workers' would make fleet policy shift as machines come and
203+
go, and the gateway's cap is meant to be explicit policy, not a computed
204+
minimum. What it does instead: when a worker's own reported `lease.maxTtlMs`
205+
is below the gateway's, the gateway logs a warning naming the worker and
206+
both values the moment that worker's view is built (at join, and again if
207+
the mismatch changes on a later refresh) — loud enough to catch the
208+
misconfiguration without silently overriding it.
209+
201210
Everything else — `capacity.*`, `idle.*`, `warmPool.*`, `health.*`,
202211
`stalledTransition.*`, `drivers.*`, `ios.slim.*`, `diskPressure.*`,
203212
`downloads.*`, and the worker-side `gateway.url`/`gateway.token`/

docs/adr/0005-gateway-and-worker-modes.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,13 @@ physical machine.
178178
slow periodic tick as a backstop. `config.get` is read **once per
179179
connect** and not on the refresh path — config is daemon input, read at
180180
start, so a worker whose configuration changed has restarted and
181-
reconnected anyway — and the gateway keeps exactly one field out of it:
182-
the worker's effective `downloads.policy`, which the view carries and
183-
routing (requirement 13) needs in order to know whether a worker may
184-
install a missing runtime at all. It is a routing input, never an
185-
override: the worker still clamps `allowDownload` through its own policy.
181+
reconnected anyway — and the gateway keeps two fields out of it: the
182+
worker's effective `downloads.policy`, which the view carries and routing
183+
(requirement 13) needs in order to know whether a worker may install a
184+
missing runtime at all (a routing input, never an override: the worker
185+
still clamps `allowDownload` through its own policy); and the worker's own
186+
`lease.maxTtlMs`, which the view also carries so the gateway can warn when
187+
it is lower than the gateway's own (requirement 15).
186188

187189
### Worker registry (gateway side)
188190

@@ -283,7 +285,17 @@ physical machine.
283285
lease's width is decided at the gateway — but what it dispatches is an
284286
ordinary `lease.request`, so a worker with a lower cap still refuses it.
285287
Keep a gateway's `lease.maxTtlMs` at or below every worker's, or
286-
requests the gateway accepts fail on whichever machine they land on.
288+
requests the gateway accepts fail on whichever machine they land on. The
289+
gateway warns rather than clamps: when a worker's view is built and its
290+
own reported `lease.maxTtlMs` is below the gateway's, the gateway logs a
291+
warning naming the worker and both values, at join and again if the
292+
mismatch changes on a later refresh. It does not lower its own cap to
293+
match — that would make a fleet's policy drift with whichever machines
294+
happen to be connected, contradicting "a fleet lease's width is decided
295+
at the gateway" two sentences up — so the requests already described
296+
above still fail on the low-capped worker; the warning only makes the
297+
cause visible where it was decided, rather than leaving an operator to
298+
find it from the failures downstream.
287299
16. A gateway lease id names its worker, so renew, release, and reads route
288300
without consulting any state of its own: it is the owning worker's id,
289301
then a `.`, then the worker's own lease id, and routing **splits on the

src/contract/schemas.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,18 @@ export const workerViewSchema = z.object({
557557
* that failed).
558558
*/
559559
downloads: z.object({ policy: z.enum(["never", "on-request", "always"]) }).optional(),
560+
/**
561+
* The worker's own `lease.maxTtlMs`, read once with `config.get` when the uplink connects
562+
* (and again on the periodic backstop tick, alongside the catalog and `downloads.policy`
563+
* above -- see `WorkerLink#rebuildView`). ADR 0005 §15: a fleet lease's width is decided at
564+
* the gateway's own `lease.maxTtlMs`, but what the gateway dispatches is an ordinary
565+
* `lease.request`, so a worker whose own cap is lower still refuses a request the gateway
566+
* already accepted -- this field is what lets the gateway notice that at the worker, rather
567+
* than only in the operator's documentation. Absent for a worker whose `config.get` the
568+
* gateway could not read (an incompatible worker, a call that failed, or one older than this
569+
* field).
570+
*/
571+
lease: z.object({ maxTtlMs: z.number() }).optional(),
560572
/** The worker's *own* queue depth -- local agents on that machine. The gateway's fleet queue
561573
* is reported separately by `status.get` and arrives with #118. */
562574
queueDepth: z.number().optional(),

src/daemon/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,9 @@ async function startGatewayDaemon(options: GatewayDaemonOptions): Promise<Daemon
538538
path: join(dataDirectory, "workers.json"),
539539
}),
540540
eventBus,
541+
// ADR 0005 §15: this gateway's own cap, compared against every connected worker's own
542+
// reported one so the registry can warn when a worker's is lower.
543+
leaseMaxTtlMs: config.lease.maxTtlMs,
541544
logger: logger.child("gateway"),
542545
principal: `gw:${instanceId}`,
543546
retentionMs: config.gateway.disconnectedRetentionMs,

src/gateway/dispatcher.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ function harness() {
7272
clock,
7373
drainStore: new MemoryDrainStore(),
7474
eventBus,
75+
leaseMaxTtlMs: gatewayConfig.lease.maxTtlMs,
7576
retentionMs: 24 * 60 * 60_000,
7677
});
7778
const tokens = new FakeTokens();

src/gateway/service.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { MAX_CONSECUTIVE_REFRESH_TIMEOUTS, WORKER_CALL_TIMEOUT_MS } from "./work
2323

2424
const RETENTION_MS = 24 * 60 * 60_000;
2525
const REFRESH_MS = 30_000;
26+
/** Matches `core/config.ts`'s own default (ADR 0005 §15's own cap). */
27+
const DEFAULT_LEASE_MAX_TTL_MS = 4 * 60 * 60_000;
2628

2729
/** H4: records every line at `warn` and `error` (the levels a link's own failure paths use), so
2830
* a test can assert what a failure was actually logged *as* -- not just that something failed. */
@@ -45,6 +47,7 @@ function fleet(
4547
readonly authenticate?: (
4648
credential: string | undefined,
4749
) => UplinkAuthOutcome | UplinkAuthResult;
50+
readonly leaseMaxTtlMs?: number;
4851
readonly logger?: Logger;
4952
readonly refreshIntervalMs?: number;
5053
} = {},
@@ -73,6 +76,7 @@ function fleet(
7376
},
7477
drainStore: new MemoryDrainStore(),
7578
eventBus,
79+
leaseMaxTtlMs: options.leaseMaxTtlMs ?? DEFAULT_LEASE_MAX_TTL_MS,
7680
...(options.logger === undefined ? {} : { logger: options.logger }),
7781
principal: "gw:instance-1",
7882
refreshIntervalMs: options.refreshIntervalMs ?? REFRESH_MS,
@@ -300,6 +304,75 @@ describe("GatewayService", () => {
300304
await harness.service.stop();
301305
});
302306

307+
describe("warning on a worker's lower lease.maxTtlMs (ADR 0005 §15)", () => {
308+
it("warns once the joining worker's own config.get reports a lower cap", async () => {
309+
const logger = new RecordingLogger();
310+
const harness = fleet({ leaseMaxTtlMs: 3_600_000, logger });
311+
await harness.service.start();
312+
const worker = new ScriptedWorkerClient();
313+
worker.leaseMaxTtlMs = 1_800_000;
314+
315+
await harness.join("wrk_1", worker, "mac-mini-1");
316+
await vi.waitFor(() => expect(harness.service.workers.view("wrk_1")?.lease).toBeDefined());
317+
318+
const warning = logger.warnings.find((entry) => entry.fields?.workerId === "wrk_1");
319+
expect(warning?.fields).toMatchObject({
320+
gatewayMaxTtlMs: 3_600_000,
321+
label: "mac-mini-1",
322+
workerMaxTtlMs: 1_800_000,
323+
});
324+
325+
await harness.service.stop();
326+
});
327+
328+
it("does not warn when the joining worker's own cap is at or above the gateway's", async () => {
329+
const logger = new RecordingLogger();
330+
const harness = fleet({ leaseMaxTtlMs: 3_600_000, logger });
331+
await harness.service.start();
332+
const worker = new ScriptedWorkerClient();
333+
worker.leaseMaxTtlMs = 3_600_000;
334+
335+
await harness.join("wrk_1", worker);
336+
await vi.waitFor(() => expect(harness.service.workers.view("wrk_1")?.lease).toBeDefined());
337+
338+
expect(logger.warnings.some((entry) => entry.fields?.workerId === "wrk_1")).toBe(false);
339+
340+
await harness.service.stop();
341+
});
342+
343+
it("does not repeat the warning on the periodic tick's re-read of an unchanged cap", async () => {
344+
const logger = new RecordingLogger();
345+
const harness = fleet({ leaseMaxTtlMs: 3_600_000, logger });
346+
await harness.service.start();
347+
const worker = new ScriptedWorkerClient();
348+
worker.leaseMaxTtlMs = 1_800_000;
349+
350+
await harness.join("wrk_1", worker);
351+
await vi.waitFor(() => expect(harness.service.workers.view("wrk_1")?.lease).toBeDefined());
352+
const warningsAfterJoin = logger.warnings.filter(
353+
(entry) => entry.fields?.workerId === "wrk_1",
354+
).length;
355+
expect(warningsAfterJoin).toBe(1);
356+
357+
// The periodic backstop tick re-reads config.get alongside the catalog
358+
// (`#runTick` -> `link.refresh({ includeCatalog: true })`) -- the worker's cap has not
359+
// moved, so this must not warn again.
360+
const callsBefore = worker.calls.filter((call) => call === "config.get").length;
361+
harness.clock.advance(REFRESH_MS);
362+
await vi.waitFor(() =>
363+
expect(worker.calls.filter((call) => call === "config.get").length).toBeGreaterThan(
364+
callsBefore,
365+
),
366+
);
367+
368+
expect(logger.warnings.filter((entry) => entry.fields?.workerId === "wrk_1").length).toBe(
369+
warningsAfterJoin,
370+
);
371+
372+
await harness.service.stop();
373+
});
374+
});
375+
303376
it("marks a worker incompatible when hello finds no overlapping range, and asks it nothing else", async () => {
304377
const harness = fleet();
305378
await harness.service.start();

src/gateway/service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export interface GatewayServiceOptions {
6868
readonly authenticate: (credential: string | undefined) => Promise<UplinkAuthResult>;
6969
/** `gateway.disconnectedRetentionMs` (§6). */
7070
readonly retentionMs: number;
71+
/** This gateway's own `lease.maxTtlMs` (§15) -- threaded straight into the registry, which
72+
* warns when a connected worker's own reported cap is lower. */
73+
readonly leaseMaxTtlMs: number;
7174
/** Where drained worker ids survive a restart (Decision 3). */
7275
readonly drainStore?: DrainStore;
7376
/** The principal the gateway announces to each worker at `hello`. */
@@ -101,6 +104,7 @@ export class GatewayService {
101104
// exactly `gw:<this gateway's instance id>`, the same shape requirement 27 stamps on
102105
// every requester id this gateway forwards, just without the trailing `:<requester>`.
103106
gatewayRequesterPrefix: `${options.principal}:`,
107+
leaseMaxTtlMs: options.leaseMaxTtlMs,
104108
logger: this.#logger,
105109
retentionMs: options.retentionMs,
106110
...(options.drainStore === undefined ? {} : { drainStore: options.drainStore }),

src/gateway/test-support.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ export class ScriptedWorkerClient {
8080
catalog: CatalogOutput = catalogFixture([]);
8181
/** What `config.get` reports; the view carries it as a routing input (ADR 0005 §13). */
8282
downloadPolicy: DownloadPolicy = "on-request";
83+
/** What `config.get` reports for `lease.maxTtlMs` (ADR 0005 §15) -- the routing-adjacent
84+
* counterpart to `downloadPolicy` above. Defaults comfortably above every gateway cap this
85+
* suite's fixtures use, so a test that never sets it cannot accidentally trip the new warning;
86+
* a test exercising §15 sets it explicitly. `config.get` always answers with a real
87+
* `lease.maxTtlMs` when it answers at all (the field predates this change and is not
88+
* optional on `Config`), so unlike `downloadPolicy` there is no "unset" state to script here
89+
* -- the workerViewSchema field's own optionality is `config === undefined`, exercised at
90+
* `WorkerRegistry`'s own level in `worker-registry.test.ts`, not through this fake. */
91+
leaseMaxTtlMs = 24 * 60 * 60_000;
8392
readonly calls: string[] = [];
8493
/** Set to reject every call with this error -- e.g. a protocol mismatch. */
8594
failWith: unknown;
@@ -152,11 +161,14 @@ export class ScriptedWorkerClient {
152161
}
153162

154163
// fallow-ignore-next-line unused-class-member -- reached structurally through the `SimlockAdminClient` the cast in `asClient()` produces; the audit cannot follow a member access through that.
155-
async getConfig(): Promise<{ readonly downloads: { readonly policy: DownloadPolicy } }> {
164+
async getConfig(): Promise<{
165+
readonly downloads: { readonly policy: DownloadPolicy };
166+
readonly lease: { readonly maxTtlMs: number };
167+
}> {
156168
this.calls.push("config.get");
157169
if (this.hangingCalls.has("config.get")) return new Promise<never>(() => {});
158170
this.#throwIfFailing();
159-
return { downloads: { policy: this.downloadPolicy } };
171+
return { downloads: { policy: this.downloadPolicy }, lease: { maxTtlMs: this.leaseMaxTtlMs } };
160172
}
161173

162174
// fallow-ignore-next-line unused-class-member -- reached structurally through the `SimlockAdminClient` the cast in `asClient()` produces; the audit cannot follow a member access through that.

src/gateway/worker-link.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,15 @@ export class WorkerLink {
360360
queueDepth: status.queueDepth,
361361
version: client.daemonVersion,
362362
...(catalog === undefined ? {} : { catalog: catalog.platforms }),
363-
...(config === undefined ? {} : { downloads: { policy: config.downloads.policy } }),
363+
...(config === undefined
364+
? {}
365+
: {
366+
downloads: { policy: config.downloads.policy },
367+
// ADR 0005 §15: the one other field this gateway keeps out of a worker's config,
368+
// alongside `downloads.policy` above -- `WorkerRegistry#refresh` is where it is
369+
// compared against the gateway's own `lease.maxTtlMs` and warned about.
370+
lease: { maxTtlMs: config.lease.maxTtlMs },
371+
}),
364372
});
365373
}
366374

src/gateway/worker-registry.test.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,45 @@ import { describe, expect, it } from "vitest";
22

33
import { EventBus, type EventEnvelope } from "../bus/index.js";
44
import { PROTOCOL_VERSION_RANGE } from "../contract/index.js";
5-
import { FakeClock } from "../ports/index.js";
5+
import { FakeClock, type Logger } from "../ports/index.js";
66
import { MemoryDrainStore } from "./drain-store.js";
77
import { leaseFixture } from "./test-support.js";
88
import { WorkerRegistry } from "./worker-registry.js";
99

1010
const RETENTION_MS = 24 * 60 * 60_000;
11+
/** Matches `core/config.ts`'s own default, so a test that does not care about ADR 0005 §15's
12+
* warning gets a cap no fixture's `ttlMs` ever comes near. */
13+
const DEFAULT_LEASE_MAX_TTL_MS = 4 * 60 * 60_000;
1114
/** Matches `service.test.ts`'s `principal: "gw:instance-1"` -- ADR 0005 §14/§27's own-lease
1215
* prefix is that principal plus a trailing `:`. */
1316
const GATEWAY_REQUESTER_PREFIX = "gw:instance-1:";
1417

18+
/** Records every `warn` call, the same shape `service.test.ts`'s own `RecordingLogger` uses --
19+
* §15's warning is asserted on directly rather than only on the view it leaves behind. */
20+
class RecordingLogger implements Logger {
21+
readonly warnings: Array<{ message: string; fields?: Record<string, unknown> }> = [];
22+
23+
debug(): void {}
24+
info(): void {}
25+
warn(message: string, fields?: Record<string, unknown>): void {
26+
this.warnings.push(fields === undefined ? { message } : { fields, message });
27+
}
28+
error(): void {}
29+
child(): Logger {
30+
return this;
31+
}
32+
}
33+
1534
function registry(
1635
options: {
1736
readonly drainStore?: MemoryDrainStore;
1837
/** Omit to get the default fixture prefix; pass `null` explicitly (H3) to build a registry
1938
* with no `gatewayRequesterPrefix` at all, the way `dispatcher.test.ts`'s harness does. */
2039
readonly gatewayRequesterPrefix?: string | null;
40+
/** ADR 0005 §15's own cap. Defaults to `core/config.ts`'s own default so a test that never
41+
* reports a worker `lease.maxTtlMs` cannot accidentally trip the new warning. */
42+
readonly leaseMaxTtlMs?: number;
43+
readonly logger?: Logger;
2144
} = {},
2245
) {
2346
const clock = new FakeClock(1_000);
@@ -31,9 +54,11 @@ function registry(
3154
const workers = new WorkerRegistry({
3255
clock,
3356
eventBus,
57+
leaseMaxTtlMs: options.leaseMaxTtlMs ?? DEFAULT_LEASE_MAX_TTL_MS,
3458
retentionMs: RETENTION_MS,
3559
...(gatewayRequesterPrefix === undefined ? {} : { gatewayRequesterPrefix }),
3660
...(options.drainStore === undefined ? {} : { drainStore: options.drainStore }),
61+
...(options.logger === undefined ? {} : { logger: options.logger }),
3762
});
3863
return { clock, events, workers };
3964
}
@@ -113,6 +138,79 @@ describe("WorkerRegistry", () => {
113138
expect(workers.view("wrk_1")).toBeUndefined();
114139
});
115140

141+
describe("warning on a worker's lower lease.maxTtlMs (ADR 0005 §15)", () => {
142+
it("warns once a worker's own cap is refreshed in below the gateway's", () => {
143+
const logger = new RecordingLogger();
144+
const { workers } = registry({ leaseMaxTtlMs: 3_600_000, logger });
145+
workers.connected("wrk_1", "mac-mini-1", "0.3.0");
146+
147+
workers.refresh("wrk_1", { lease: { maxTtlMs: 1_800_000 } });
148+
149+
expect(logger.warnings).toHaveLength(1);
150+
expect(logger.warnings[0]).toMatchObject({
151+
fields: {
152+
gatewayMaxTtlMs: 3_600_000,
153+
label: "mac-mini-1",
154+
workerId: "wrk_1",
155+
workerMaxTtlMs: 1_800_000,
156+
},
157+
});
158+
});
159+
160+
it("does not warn when a worker's own cap is at or above the gateway's", () => {
161+
const logger = new RecordingLogger();
162+
const { workers } = registry({ leaseMaxTtlMs: 3_600_000, logger });
163+
workers.connected("wrk_1", undefined, "0.3.0");
164+
165+
// Exactly equal, and then strictly above -- neither is "below".
166+
workers.refresh("wrk_1", { lease: { maxTtlMs: 3_600_000 } });
167+
workers.refresh("wrk_1", { lease: { maxTtlMs: 7_200_000 } });
168+
169+
expect(logger.warnings).toEqual([]);
170+
});
171+
172+
it("does not warn, and does not crash, when a worker reports no cap at all", () => {
173+
const logger = new RecordingLogger();
174+
const { workers } = registry({ leaseMaxTtlMs: 3_600_000, logger });
175+
workers.connected("wrk_1", undefined, "0.3.0");
176+
177+
// No `lease` key on the snapshot: exactly what an event-driven refresh sends today
178+
// (`config.get` is only read alongside the catalog), and what an incompatible or
179+
// config.get-failed worker's view carries forever.
180+
expect(() => workers.refresh("wrk_1", { queueDepth: 1 })).not.toThrow();
181+
182+
expect(workers.view("wrk_1")?.lease).toBeUndefined();
183+
expect(logger.warnings).toEqual([]);
184+
});
185+
186+
it("does not repeat the warning on a later refresh reporting the same lower cap", () => {
187+
const logger = new RecordingLogger();
188+
const { workers } = registry({ leaseMaxTtlMs: 3_600_000, logger });
189+
workers.connected("wrk_1", undefined, "0.3.0");
190+
191+
// The periodic backstop tick re-reads config.get (and so `lease.maxTtlMs`) on every
192+
// refresh alongside the catalog (`WorkerLink#rebuildView`) -- an unchanged report must
193+
// not turn into a warning every `refreshIntervalMs`.
194+
workers.refresh("wrk_1", { lease: { maxTtlMs: 1_800_000 } });
195+
workers.refresh("wrk_1", { lease: { maxTtlMs: 1_800_000 } });
196+
workers.refresh("wrk_1", { lease: { maxTtlMs: 1_800_000 } });
197+
198+
expect(logger.warnings).toHaveLength(1);
199+
});
200+
201+
it("warns again if the cap drops further while already below the gateway's", () => {
202+
const logger = new RecordingLogger();
203+
const { workers } = registry({ leaseMaxTtlMs: 3_600_000, logger });
204+
workers.connected("wrk_1", undefined, "0.3.0");
205+
206+
workers.refresh("wrk_1", { lease: { maxTtlMs: 1_800_000 } });
207+
workers.refresh("wrk_1", { lease: { maxTtlMs: 900_000 } });
208+
209+
expect(logger.warnings).toHaveLength(2);
210+
expect(logger.warnings[1]?.fields).toMatchObject({ workerMaxTtlMs: 900_000 });
211+
});
212+
});
213+
116214
it("marks a version-mismatched worker incompatible with both ranges, and rejects it", () => {
117215
const { events, workers } = registry();
118216

0 commit comments

Comments
 (0)