Repository navigation
The warm pool learns the kinds it leases and keeps them ready, across restarts #776
Description
Part of #771.
Scope
A worker with no warmPool.targets keeps what it has leased: for every kind it granted a lease of, as many devices as were held at once in the last 24 hours, at least one, counting the ones leased right now, newest kinds first within today's budget, and booted again after a daemon restart. A newer learned kind may take an older learned kind's slot. A kind is forgotten when its model or runtime is gone, after three failed boots in a row, when its platform holds 32 kinds and it is the oldest, and on simlock nuke. The "released under idle.shutdownAfterMs" reboot rule goes: a learned kind covers it.
Kept whole at about 25 files: the record, the policy's use of it and the converger's learning are one behaviour a test can only prove together (a lease ends, a device comes back), and the reboot rule it replaces must go in the same change or the two rules would fight over the same device.
In short
Today a released device is booted back only if its lease ended under 10 minutes ago and a slot is free; nothing is booted ahead of the first request of a kind, and a restart starts cold. After this PR the pool learns a kind (platform, model, OS version, mode, image tag, what sameSpec compares) from every lease it grants and keeps peak devices of it, where peak is the most leases of that kind held at the same moment in the last 24 hours (counted from now, a lease held across the window's start counts, never below 1). A kind has its idle ready devices plus its leased and reclaiming ones, so while a burst is held nothing more is made, and the released devices come back ready. The record lives in the pool's own state.db tables, so the first pass after a restart boots them again.
flowchart LR
G[lease.granted] --> L["record the kind and the lease (new) *"]
E[lease ends] --> L
L --> P["pass: configured targets, then learned kinds newest first (changed) *"]
P --> F{fits the budget?}
F -- yes --> B[boot a shut-down device of the kind, else create one]
F -- no, slot held by an older learned kind's idle device --> D["shut it down: displaced (new) *"]
D --> B
R[daemon start] --> P
N[simlock nuke] --> X["record cleared (new) *"]
- learned kind: a kind a lease was granted of on this worker, kept ready like a target, origin
learned. - displaced: an idle ready device of an older learned kind shut down (initiator
warm-pool; the reasondisplacedis in the daemon log and the catalog's prose, sincedevice.shutdowncarries no reason field) so a newer learned kind short for a running slot can boot or create into it. Never a configured target's device, never one that serves a waiting request. - forgotten: the kind's rows are deleted; its next lease learns it again.
User's session, a worker with no config:
$ simlock lease --platform ios --device "iPhone 16" --os 18.4 --agent-id a1
# ... source: provisioned (60 s); work; release
# today: erased and shut down; booted back only if a slot is free within 10 min; after 10 min stays down
# after: erased, shut down, booted back at once and kept ready (not shut down idle, whatever the time);
# while a1 held it, nothing more was made: the kind had one, its peak
$ simlock config set warmPool.enabled false && simlock daemon stop && simlock status # the pool off shuts it down
$ simlock config set warmPool.enabled true && simlock daemon stop && simlock status # on again: a restart
# today: nothing ready
# after: the iPhone 16 / 18.4 is booting, then ready, before any lease: the record kept the kind while the pool was off
$ simlock lease --platform ios --device "iPhone 16" --os 18.4 --agent-id a2
# after: source: warm
$ simlock nuke --yes
# after: nothing is booted back; the record is empty until the next lease
What stays ready, budget of two running iOS slots, no configured targets:
| Leases in the last 24 h | Today | After |
|---|---|---|
| one iPhone 16 / 18.4, ended 5 min ago | 1 ready (until 10 min idle) | 1 ready, kept |
| one iPhone 16 / 18.4, ended 2 h ago | 0 | 1 ready, kept |
| three iPhone 16 / 18.4 held at once this morning, all released | 0 | 2 ready (budget), short running-limit for the third |
| three iPhone 16 / 18.4 held right now, peak 3 | 0 extra | 0 extra: the kind has three |
| iPhone 16 / 18.4 at 09:00, iPhone 15 / 17.5 at 10:00 | 0 | 1 of each |
| three kinds, one lease each, newest an hour ago | 0 | the two newest ready, the oldest short running-limit; its idle device, if any, is displaced |
| a kind whose runtime was removed | — | forgotten on the next pass, nothing booted |
| a kind that failed to boot 3 times in a row | — | forgotten, one warning logged |
warmPool.enabled: false |
nothing kept | nothing kept, nothing learned |
If this goes wrong, a user would notice devices booting that nobody asked for, a device shut down and booted again in a loop between two kinds, or a worker that holds more RAM than its running limit allows.
Technical spec
Facts
Commit 97bc8eaf.
| # | Fact | Source |
|---|---|---|
| F1 | lease.granted carries leaseId, deviceId, requester, requestId, source, platform; emitted after the grant's commit |
src/bus/index.ts:28-44; src/leasing/lease-lifecycle.ts:101-115 |
| F2 | lease.released carries leaseId, deviceId, reason, ownerId; lease.expired carries leaseId, deviceId, ownerId |
src/bus/index.ts:43-57 |
| F3 | Startup ends leases as lease.expired or lease.released in leasing's reconcile, before core.converge; the pool subscribes in start(), called at the end of converge |
src/leasing/lease-reconciler.ts:22-23; src/core/create-core.ts:347-350; docs/internal/flows/startup.md:68 |
| F4 | The pool's triggers include lease.granted, lease.released, daemon.started |
src/core/warm-pool/converger.ts:685-695 |
| F5 | The pool reads registry.snapshot.virtualDevices and leases; a virtual device's spec has platform, model, osVersion, mode?, imageTag?; sameSpec compares those plus physical; a physical device's spec has physical: true |
src/core/warm-pool/converger.ts:67-73; src/core/domain.ts:29-48, :59-67, :93-102 |
| F6 | The keep rule: bootable devices with now - lastLeaseEndedAt < shutdownAfterMs, newest first, reason recently-released, when hasWarmRoom |
src/core/warm-pool/policy.ts:315-326 |
| F7 | neverLeasedIdle shuts down a never-leased ready device no target or spared target keeps after shutdownAfterMs; targetedDevices lists each target's ready devices up to its count; the reaper's idle-shutdown spares view.targeted, filled from core.targetedDevices() |
src/core/warm-pool/policy.ts:381-430; src/core/create-core.ts:355-357; src/core/reaper.ts:212; src/core/cleanup/idle-shutdown.ts:9 |
| F8 | Targets of one spec merge with counts summed; readyOfKind counts ready, unleased, unclaimed devices, oldest first; fillTarget/addOne boot a shut-down device of the kind before creating, refuse by device-limit, running-limit, reserve, ram-budget; "A target never shuts a device down" |
src/core/warm-pool/policy.ts:331-359, :456-560 |
| F8a | evaluate returns one TargetReport per planned entry; #once stores [...refused, ...plan.targets] as #reports, which figures() returns after a pass and #note logs shorts from |
src/core/warm-pool/converger.ts:296-298, :225-247, :366-383 |
| F8b | neverLeasedIdle spares only [...view.targets, ...view.spared]'s devices |
src/core/warm-pool/policy.ts:403-430 |
| F8c | #announceMissed compares report.ready with report.count and maps a report to configured indexes by spec |
src/core/warm-pool/converger.ts:398-432 |
| F9 | RetrySchedule.failed counts failures in a row per spec, delay 1, 2, 4 ... minutes capped at 10, no limit; a creation failure logs warm pool creation of a device failed and calls #failed |
src/core/warm-pool/retry.ts:36-43; src/core/warm-pool/converger.ts:612-619 |
| F10 | Targets are resolved every pass through acquisition.resolve; unresolvable keeps the last spec as spared; iOS resolveSpec reads the catalog on every call |
src/core/warm-pool/converger.ts:314-362; src/drivers/ios/index.ts:388-393 |
| F11 | DriverCatalog.listCatalog(platform) returns one PlatformCatalog per driver with models, runtimes, modelRuntimes, images?; a driver's read error with a platform named throws, else is logged and skipped |
src/core/driver-catalog.ts:139-158; src/core/driver.ts:150-159 |
| F11a | Android's listCatalog runs avdmanager list device on every call with no timeout; iOS's simctl calls are capped at 30 s |
src/drivers/android/device-profile-source.ts:293-297; src/drivers/android/index.ts:1321-1325; src/drivers/ios/AGENTS.md "State and timers" |
| F11b | The e2e fake's listCatalog lists models: [] unless the test scripts knownModels; e2e/warm-pool-reserve.test.ts scripts { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] } |
e2e/fake-driver/fake-driver.ts:609-623; e2e/warm-pool-reserve.test.ts:10 |
| F11c | device.shutdown's payload is { deviceId, initiator }, no reason field |
src/bus/index.ts:200 |
| F11d | RetrySchedule keeps count per spec but exposes only mayAttempt, failed, succeeded, nextAttemptAt |
src/core/warm-pool/retry.ts:24-60 |
| F11e | SQLite treats NULLs as distinct in a UNIQUE constraint | SQLite documentation, "CREATE TABLE", UNIQUE constraints |
| F11f | createCore( callers: src/daemon/main.ts:340, create-core.test.ts:68, physical-enrollment.test.ts:36, :396, quarantine-coordinator.test.ts:1032, :1206, src/leasing/testing.ts; new NukeService( four times in src/core/nuke-service.test.ts; src/core/nuke.test.ts tests the Nuke facade over createTestEngine |
rg -n "createCore\(|new NukeService" src; src/core/nuke.test.ts:8-12 |
| F11g | daemon stop leaves devices running and waits for the pool's running boots (drain → settle awaits the flights); emulators outlive the daemon; TestEnv.restartDaemon() stops and starts, withConfig writes keys, restarts, runs, restores and restarts again |
src/core/warm-pool/converger.ts:172-177; src/daemon/main.ts:497, :550; src/drivers/android/AGENTS.md "Emulators are spawned unref'd and outlive the daemon"; e2e/helpers/env.ts:166-172 |
| F11i | With the pool off every idle ready device is shut down (disabled) and nothing is learned, but the record's rows are untouched |
src/core/warm-pool/policy.ts:149-158; this task, "In short" table last row |
| F11h | core.converge throwing fails the start; WarmPool.start() runs inside it |
docs/internal/flows/startup.md:77; src/core/create-core.ts:347-350 |
| F12 | NukeService.#runNuke: beginMaintenance on acquisition and leases, release all, reset each device, endMaintenance; its options are devices, acquisition, leases, registry, logger |
src/core/nuke-service.ts:41-46, :77-107 |
| F13 | While maintenanceActive the pool does nothing and marks shut-down devices as reset, never booted back unasked |
src/core/warm-pool/converger.ts:283-291; src/core/warm-pool/policy.ts:319 |
| F14 | Shutdown proposal reasons today: disabled, over-budget, never-leased-idle; device.shutdown's catalog row lists the pool's reasons |
src/core/warm-pool/policy.ts:149-158, :263, :428; docs/EVENTS.md:49; docs/internal/EVENTS.md:87 |
| F15 | ADR 0036 §2: each owner creates its own STRICT tables and keeps its own schema version, one migration per step; #754's Shared values: SqliteSchemaOwner { name, version, migrations } in src/ports/sqlite.ts, StateDatabase { database, transaction(work), close() } from src/state/, worker owner list built in src/daemon/main.ts, openMemoryStateDatabase(owners) in src/state/testing.ts |
docs/internal/adr/0036-*.md §2 (PR #755); #754 "Shared values" rows "The state module", "SqliteSchemaOwner", "Owner lists"; #761 "Surface and extension points" |
| F16 | createCore options carry no state handle; WarmPool options are acquisition, capacity, claims, clock, config, decisions, eventBus, hasDriver, idle, lifecycle, logger, provisioner, registry |
src/core/create-core.ts:40-69; src/core/warm-pool/converger.ts:55-73 |
| F17 | figures() reports #reports after a pass, else the configured targets from config |
src/core/warm-pool/converger.ts:225-247 |
| F18 | Docs saying what this task changes: idle.shutdownAfterMs and warmPool.enabled rows, the lease.identity.ios boot-cost text |
docs/CONFIGURATION.md:17, :19, :29 |
| F19 | Docs: the class lease order and "a released simulator is booted back when the running limit has room" | docs/CLI.md:594-604; docs/CONFIGURATION.md:29 |
| F20 | The manual's step 4 keep rule and known gaps; the startup flow's step 4; device selection's warm target rows | src/core/warm-pool/AGENTS.md ("How it works" 4, "Known gaps"); docs/internal/flows/startup.md:68; docs/internal/flows/device-selection.md:27, :53, :84 |
| F21 | The fake driver's reclaim returns script.reclaimResult ?? "ready" and strategy erase by default |
e2e/fake-driver/fake-driver.ts:472-484 |
| F22 | TestEnv.restartDaemon() stops with simlock daemon stop and starts again; withConfig writes keys and restarts |
e2e/helpers/env.ts:166-172, :295 |
| F23 | e2e/slow-warm-pool.test.ts runs real simctl and emulator targets with a nuke teardown, tags slow, ios, android |
e2e/slow-warm-pool.test.ts:13-23, :505-512 |
| F24 | #announceMissed keys the warm-pool.target-missed edge by configured target index (#owners, #covers) |
src/core/warm-pool/converger.ts:391-432 |
| F25 | Over-budget shutdowns are LRU by compareLeastRecentlyUsed, never a device that serves a waiting request |
src/core/warm-pool/policy.ts:248-267; src/core/idle-order.ts:36-45 |
Modules touched
Searches: recently-released, lastLeaseEndedAt, targetedDevices, ResolvedTarget, resolveTargets, NukeServiceOptions, WarmPoolOptions, createCore(, warmPoolReader over src/, e2e/, docs/.
src/core/warm-pool/learned.ts(new): the ownerwarm-pool(itsSqliteSchemaOwner),LearnedKindsover aStateDatabase:learn(spec, leaseId, at),ended(leaseId, at),closeOpen(heldLeaseIds, at),forget(spec),clear(),kinds(now)→{ spec, count, lastLeasedAt }[], newest first; pruning and the 32-per-platform cap insidelearn. Every method catches nothing: the converger does.src/core/warm-pool/policy.ts:view.learned(kinds with count and last lease);TargetReport.origin(configuredorlearned) andconfiguredCount(the configured count of a merged entry);planTargetstakes configured then learned; a kind in both is one entry, originconfigured, count the larger, in the configured position, needing the larger of (configured count − ready) and (learned count − has), wherehas= ready idle + leased + reclaiming devices of the kind (F8); the displacement rule;boots()loses the F6 rule;targetedDevicesandneverLeasedIdle's kept set count learned kinds (F8b).src/core/warm-pool/retry.ts:failures(spec)answers the count in a row (F11d).src/core/warm-pool/converger.ts: subscribes tolease.granted,lease.released,lease.expiredfor learning (F1, F2); closes open rows atstart()againstregistry.snapshot.leases(F3); a catalog check that runs beside the pass, onelistCatalog(platform)per platform with a learned kind, bounded to 30 s by the clock (F11a), whose result the next pass applies: a kind whose model is not inmodelsor whose OS version is not inmodelRuntimes[model], or a tagged kind with noimagesentry of that runtime and tag, or whose platform has no driver, is forgotten; a read that throws, times out or has not answered yet keeps every kind of that platform and plans nothing for them that pass (ADR 0038 §4); forgets a kind on its third failure in a row (F9, F11d) with onewarn;forget()for nuke;figures()and#notereadorigin: configuredreports only in this task (F8a), so status is unchanged;#announceMissedjudges and reports a merged entry byconfiguredCount(F8c); everyLearnedKindscall is wrapped: a throw is logged once per pass or event and the pool goes on without learned kinds until a read succeeds.src/core/warm-pool/index.tsandsrc/core/index.ts: exportwarmPoolOwner.src/core/create-core.ts: optionsstate: StateDatabaseandcatalog: Pick<DriverCatalog, "listCatalog">for the pool (F16);NukeServicegetswarmPool: { forget }. EverycreateCore(caller of F11f passes them.src/core/nuke-service.ts: callswarmPool.forget()afterbeginMaintenanceand before resetting devices (F12).src/daemon/main.ts:warmPoolOwnerin the worker owner list (F15);stateintocreateCore.src/leasing/testing.tsand the test files of F11f: build the core overopenMemoryStateDatabase([... , warmPoolOwner]).- Tests:
src/core/warm-pool/learned.test.ts(new),policy.test.ts,converger.test.ts,retry.test.ts,src/core/reaper.test.ts,src/core/nuke-service.test.ts(F11f),e2e/warm-pool-learned.test.ts(new),e2e/warm-pool-reserve.test.ts(its expectedmakeReadycounts re-derived with learning on; its end states hold, since the reserve is unchanged and held devices count),e2e/slow-warm-pool.test.ts. - Docs: F18, F19, F20;
docs/EVENTS.md:49anddocs/internal/EVENTS.md:87(prose: the pool also shuts a device down to displace it);docs/CLI.mdsimlock nuke(clears the record);docs/internal/ARCHITECTURE.mdif it names the pool's state;src/core/warm-pool/AGENTS.md(files, steps, state table, known gaps); the ADR README is the ADR PR's.
flowchart LR
M["daemon/main.ts *"] --> ST[state openStateDatabase]
M --> CC["createCore *"]
CC --> WP["warm-pool converger *"]
WP --> LK["warm-pool learned (new) *"]
LK --> ST
WP --> PO["warm-pool policy *"]
WP --> CAT[core DriverCatalog]
WP --> BUS[bus]
NK["core NukeService *"] --> WP
RP[core reaper] --> WP
Surface and extension points
src/core/warm-pool/index.ts grows by warmPoolOwner (the SqliteSchemaOwner), for the composition root's owner list. No new module directory.
The tables
Owner warm-pool, version 1, both STRICT, every value a column:
| Table | Key | Columns |
|---|---|---|
warm_kinds |
id (integer) |
platform TEXT NOT NULL, model TEXT NOT NULL, os_version TEXT NOT NULL, mode TEXT NOT NULL (slim or full), image_tag TEXT NOT NULL ("" when absent, so the unique constraint holds, F11e), last_leased_at INTEGER NOT NULL; unique over the five spec columns |
warm_kind_leases |
lease_id TEXT |
kind_id INTEGER NOT NULL (references warm_kinds), granted_at INTEGER NOT NULL, ended_at INTEGER (NULL while held) |
Rules: learn inserts the kind or moves its last_leased_at, inserts the lease row, deletes lease rows with ended_at < now - 24 h, then, if the platform holds more than 32 kinds, deletes the kinds with the oldest last_leased_at and their lease rows, all in one transaction. kinds(now) computes each kind's count as the peak of overlapping [granted_at, ended_at ?? now] intervals clipped to [now - 24 h, now], at least 1. forget and clear delete rows. The registry never reads these tables, and they never name a device id.
Contract and event changes
None on the wire: status.get is unchanged in this task (figures() returns origin: configured reports only, F8a, F17; the last task adds learned kinds). Events: no payload change; device.shutdown's catalog rows add the displacement to the initiator's prose (F11c); no new event. warm-pool.target-missed keeps firing for configured targets only (F24) and judges a merged entry by its configured count (F8c), so a configured target met at its own count emits nothing however short the learned count is. Searched src/contract for targets, warmPool: no change.
Other code on the same state
Searched lastLeaseEndedAt, targeted, state === "shutdown", beginMaintenance, listCatalog( over src/: 9 files.
- The reaper's idle-shutdown (F7): spares learned kinds' devices through
targetedDevices, before the first pass too (targeted()resolves first, as for configured targets); a device beyond a kind's count is shut down afteridle.shutdownAfterMsas today. Both act onreadydevices under their own claims; the pool'scleanupclaim and the reaper's are the same kind, the second finds the first. neverLeasedIdle(F8b): a device the pool created for a learned kind has nolastLeaseEndedAt; it is in the kept set, so it is not shut down as never leased and booted again in a loop. One beyond the kind's count is shut down afteridle.shutdownAfterMs, as a configured target's surplus is today.- Over-budget and displacement, both in
evaluate: over-budget runs first and releases slots; displacement only when a learned kind is still short for a running slot after that; a device proposed by one istakenfor the other. - Leasing's eviction for a request: a request evicts an LRU idle device regardless of kind (ADR 0017 §3); the next pass sees the slot taken and reports the kind short. The pool never shuts a device down that serves a waiting request (F25, kept).
- Nuke (F12, F13):
forget()runs under maintenance, before devices are reset, so the pass afterendMaintenancefinds no kind; devices shut down under nuke stay down (F13). - The registry's own
state.dbtransactions: separate owner, separate transactions; no row is shared. - A second daemon on the same
state.db: refused at open by state.db opens and checks at start, and the worker's registry lives in it #761, not here.
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
LearnedKinds.learn on lease.granted |
ok | rows written in one transaction | — |
| throws (database error) | logged once per error, the grant is unaffected (the pool subscribes after the commit, F1); the kind is not learned | — | |
| device not in the snapshot, or physical | nothing recorded | — | |
LearnedKinds.ended |
lease row missing | nothing | — |
listCatalog(platform) beside the pass |
ok, kind listed | kind kept | 30 s by the pool's clock, from the call; at most one pending read per platform: a pass starts none while one is pending, and a timed-out read is abandoned (its late answer discarded) so the next pass may start a fresh one; an avdmanager that hangs (F11a) leaves one stuck process per 30 s until it ends, accepted here as the driver's gap, not the pool's |
| ok, model, OS or tagged image missing | kind forgotten on the next pass, one info log |
— | |
| throws or times out | every kind of the platform kept, none planned that pass (ADR 0038 §4); boots resume after a read succeeds | 30 s | |
| no driver | kind forgotten (no-driver) |
— | |
LearnedKinds.closeOpen at start() |
throws | logged once; start() goes on, the daemon starts (F11h); every later pass calls closeOpen again until one succeeds |
— |
LearnedKinds.kinds in #view() or targeted() |
throws | logged once per pass; the pass runs with no learned kinds, configured targets and queue work unaffected | — |
| boot or creation of a learned kind | third failure in a row | kind forgotten, one warn with the kind and the last error |
— |
NukeService → forget() |
throws | logged, nuke goes on | — |
State written: the two tables. A restart between learn's steps leaves nothing half written (one transaction). A restart with a lease row open whose lease ended while no daemon ran: closed at start() with the start time. Two writers at once: the bus calls subscribers synchronously, one at a time.
Rules in play
- ADR 0038 §1–§5: learned kinds, the 24-hour peak, "newest kinds fill first ... may displace an older one", forgetting, "the record lives in
state.db, owned by the pool". - ADR 0036 §2, "each owner creates its own tables and keeps its own schema version".
- ADR 0017 §3, "the pool reacts to committed facts on the bus": learning subscribes to the bus, never to the lease path. §5 is narrowed by 0038 as its status line says.
- Architecture rule 16 (pure policy): the peak is computed in
learned.tsand passed in the view;evaluatestays pure. - Safety rule 2 (revalidate before acting): a displacement shutdown goes through
#shutdown's revalidation like any other. - Testing rule 10: every "newest first", "never a configured target's device" and "forgotten when" claim lists its cases below.
Tests
Seams: LearnedKinds over openMemoryStateDatabase([warmPoolOwner]) in src/core/warm-pool/learned.test.ts (model: src/core/registry.test.ts after #761); evaluate in policy.test.ts; WarmPool in converger.test.ts; CleanupReaper in src/core/reaper.test.ts; NukeService in src/core/nuke.test.ts; the CLI over the fake driver in e2e/warm-pool-learned.test.ts (model: e2e/warm-pool-reserve.test.ts); real devices in e2e/slow-warm-pool.test.ts.
LearnedKinds.kindscounts a kind's peak over the last 24 hours, at least one (cases: three overlapping leases → 3; two in sequence → 1; one granted 25 h ago and still held → 1; one ended 24 h and 1 ms ago → pruned, 1; no lease rows → 1), newestlast_leased_atfirst, and a full untagged kind leased twice is one row (F11e).RetrySchedule.failures(spec)answers the failures in a row and 0 after a success (retry.test.ts).LearnedKinds.learnkeeps at most 32 kinds per platform, dropping the oldest by last lease with its lease rows, and a kind of the other platform is not counted (cases: 33rd iOS kind; 32 iOS plus 1 Android).LearnedKindssurvives a reopen of the same file, andforgetandclearremove the rows (cases: forget one of two; clear).warmPoolOwnerversion 1 creates bothSTRICTtables with the columns above (extends the owner conformance the state module gives, if any; else a directsqlite_masterread).evaluatefills configured targets in config order, then learned kinds newest first, booting a shut-down device of the kind before creating, and counts a learned kind's leased and reclaiming devices as had (cases: two learned kinds and room for one → the newer; peak 3 with three leased → no proposal; peak 3 with one leased and one ready → one proposal; a kind both configured (1) and learned (2) with one leased and nothing ready → one entry, originconfigured, count 2,configuredCount1, one proposal, in the configured position).evaluatekeeps a learned kind's never-leased device out ofnever-leased-idlewhile the kind wants it, and proposes one beyond its count afteridle.shutdownAfterMs(extends "shuts a never-leased device no target counts down ...").evaluatedisplaces: a learned kind short for a running slot gets the LRU idle ready device of an older learned kind shut down with reasondisplacedand its own boot or creation proposed, and never displaces a configured target's device, a device that serves a waiting request, for a kind short forram-budget,device-limitorreserve, or for a configured target (one case each).evaluateproposes norecently-releasedboot: a shut-down device released a moment ago whose kind is not learned is left down (replaces "with room for one boot and two recently released shut-down devices ..." and "does not boot a shut-down device released longer ago ...").targetedDeviceslists a learned kind's ready devices up to its count, andWarmPool.targeted()includes them before the first pass and after a restart (converger.test.ts;reaper.test.tsalready proves the reaper spares whatever the set names).WarmPoollearns onlease.grantedthe leased device's kind, closes it onlease.releasedandlease.expired, skips a physical device and learns nothing withwarmPool.enabled: false(cases: virtual grant; physical grant; pool off).WarmPool.start()closes open lease rows whose lease the registry no longer holds, and the first pass boots the recorded kinds (restart case over one memory database shared by twoWarmPools).WarmPoolstarts one catalog read per platform with a learned kind beside a pass, never two at once, and on the next pass forgets a learned kind whose model is not inmodels, whose OS is not inmodelRuntimes[model], whose tag has no image, or whose platform has no driver, and keeps every kind of a platform whose read throws or passes 30 s on the fake clock (one case each; thelistCatalogcall count asserted; a pass is not delayed by a pending read).WarmPool.figures()and the short log list configured targets only, with a learned kind short in the same pass (status unchanged in this task).WarmPoolemitswarm-pool.target-missedfor a merged entry by its configured count: a configured count 1 met with a learned count 2 short emits nothing, and ready 0 emits withcount: 1(extends the target-missed tests).WarmPoollogs aLearnedKindsthrow once and runs the pass with no learned kinds;start()survives acloseOpenthrow, and the next pass callscloseOpenagain and closes the rows once it succeeds.WarmPoolforgets a learned kind on its third failed boot or creation in a row, logging one warning, and a success in between resets the count (cases: 3 in a row; 2, success, 2).NukeServiceclears the record before it resets devices, and a nuke whoseforgetthrows still resets them (nuke-service.test.ts, F11f).- e2e (fake driver,
knownModelsandavailableOsVersionsscripted as F11b,reclaimResult: "shutdown"): with no targets and two iOS running slots, nothing is created while the first lease is held, the released device is booted back, and the next lease of its kind is grantedsource: warm; thenwithConfig({ warmPool: { enabled: false } })restarts and the pool shuts the device down (F11i), and the restore's restart boots it again from the record before any lease (F11g: a graceful stop would have let a boot finish, so the pool is turned off to leave the device down); aftersimlock nuke --yesand a restart nothing boots (three cases in one file). - Slow lane,
e2e/slow-warm-pool.test.ts: with no target, a released simulator and a released emulator are ready again before the next lease; withwarmPool.enabledset false and the daemon restarted both are shut down, and with it set true and the daemon restarted both are booted again from the record (realsimctland emulator, F11g, F11i).
Done when
pnpm test:changedpasses.pnpm test:e2e e2e/warm-pool-learned.test.ts e2e/warm-pool-reserve.test.tspasses.rg -n "recently-released" src docs --glob '!docs/internal/adr/**'finds nothing.- Needs real devices:
scripts/slow-e2e.sh e2e/slow-warm-pool.test.tspasses its new "with no target" case.
Out of scope
- Learned kinds in
status, the console anddoctor, and theoriginfield (the last task). - Fleet-level learning on the gateway.
- Any change to
lease.identity: a learned kind underfreshpre-creates as a configured target does.
Depends on
- state.db opens and checks at start, and the worker's registry lives in it #761
- Tokens live in state.db on both daemon kinds #758 (both add an owner to the worker's owner list in
src/daemon/main.ts) - The warm pool boots or creates one device per queued request, and the boot cap defaults from the CPU #774
Written by an agent.
Assumptions
- Assumption: a learned kind whose platform's catalog read failed, timed out or has not answered yet is kept and planned for nothing that pass, as ADR 0038 §4 says; Tests: a converger test where a pending or failed read plans no boot for the kind and a later successful read boots it (decided in delivery, pending the maintainer)
Activity
test: LearnedKinds.kinds counts a kind's peak over the last 24 hours,…
test: RetrySchedule.failures answers the failures in a row and 0 afte…
feat(warm-pool): RetrySchedule answers the failures in a row of a spec (
test: LearnedKinds keeps at most 32 kinds per platform (#776)
feat(warm-pool): a platform keeps at most 32 learned kinds (#776)
test: LearnedKinds survives a reopen and forget and clear remove the …
feat(warm-pool): the record can forget a kind and clear (#776)
test: warmPoolOwner version 1 creates both STRICT tables with their c…
test: warmPoolOwner's lease_id column reads as required in a STRICT t…
test: evaluate fills configured targets in config order, then learned…
25 remaining items
test: the RAM-budget and doctor-drift e2e account for the pool keepin…
test: cleanup e2e expects a leased kind's idle device kept, not demot…
test: the attention console specs run with the pool off, so no learne…
V3RON commentedon Oct 21, 2026
Handoff
Done: PR #792 (branch
task/776, head 851801f, stacked ontask/774) builds every Tests line in three build runs and updates the docs. The slow-lane case "with no target" ine2e/slow-warm-pool.test.tsis written, not run. Six assumptions in the PR body, including no newcreateCorecatalogoption.Not done: the audit stopped at the session budget (see its Handoff on PR #792): probes, the docs sweep, and the diffs of
policy.ts,converger.ts,retry.ts,reaper.tsandnuke-service.tsremain. No review yet. CI has not run on 851801f.Findings:
src/agent-device/host/download-cap.test.tshangs locally (untouched by this branch). The "a new 3 GiB device fits" check increate-leasing.test.tswas replaced by a refill assertion.Blocked on: nothing; the delivery session hit its 10 h budget. Next: a fresh audit run, then review round 1, then the slow lane. #774 (PR #788) stays parked for its R5 test and a post-sync fix-check.
Written by an agent.
test: audit closes the gaps probing found in the warm pool's record, …
test: the cleanup e2e stops claiming a dry run executes nothing, whic…
docs: neverLeasedIdle also spares the devices a learned kind keeps (#776
fix(warm-pool): displace only when the kind can then boot, never a sp…
fix(warm-pool): a catalog answer asks for a pass at once, a failed-re…
test: slow lane, a released targeted device may stay down once its ki…
test: slow lane, wait for the ready device of each kind after release…
V3RON commentedon Oct 21, 2026
Delivery run
Merged: none
Waiting: #776 — PR #792 passed review and the slow lane; it waits in the feature stack (#772 → #788 → #792) on #774 (parked)
Decided: #776 — plan learned kinds while a catalog read is unsettled? → plan nothing that pass (ADR 0038 §4)
Written by an agent.
feat(warm-pool): a worker with no targets keeps the kinds it has leas…
feat(warm-pool): status, doctor, console and docs show learned kinds …