Skip to content

The warm pool learns the kinds it leases and keeps them ready, across restarts #776

Closed

Description

@V3RON

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) *"]
Loading
  • 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 reason displaced is in the daemon log and the catalog's prose, since device.shutdown carries 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 owner warm-pool (its SqliteSchemaOwner), LearnedKinds over a StateDatabase: 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 inside learn. Every method catches nothing: the converger does.
  • src/core/warm-pool/policy.ts: view.learned (kinds with count and last lease); TargetReport.origin (configured or learned) and configuredCount (the configured count of a merged entry); planTargets takes configured then learned; a kind in both is one entry, origin configured, count the larger, in the configured position, needing the larger of (configured count − ready) and (learned count − has), where has = ready idle + leased + reclaiming devices of the kind (F8); the displacement rule; boots() loses the F6 rule; targetedDevices and neverLeasedIdle'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 to lease.granted, lease.released, lease.expired for learning (F1, F2); closes open rows at start() against registry.snapshot.leases (F3); a catalog check that runs beside the pass, one listCatalog(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 in models or whose OS version is not in modelRuntimes[model], or a tagged kind with no images entry 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 one warn; forget() for nuke; figures() and #note read origin: configured reports only in this task (F8a), so status is unchanged; #announceMissed judges and reports a merged entry by configuredCount (F8c); every LearnedKinds call 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.ts and src/core/index.ts: export warmPoolOwner.
  • src/core/create-core.ts: options state: StateDatabase and catalog: Pick<DriverCatalog, "listCatalog"> for the pool (F16); NukeService gets warmPool: { forget }. Every createCore( caller of F11f passes them.
  • src/core/nuke-service.ts: calls warmPool.forget() after beginMaintenance and before resetting devices (F12).
  • src/daemon/main.ts: warmPoolOwner in the worker owner list (F15); state into createCore.
  • src/leasing/testing.ts and the test files of F11f: build the core over openMemoryStateDatabase([... , 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 expected makeReady counts 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:49 and docs/internal/EVENTS.md:87 (prose: the pool also shuts a device down to displace it); docs/CLI.md simlock nuke (clears the record); docs/internal/ARCHITECTURE.md if 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
Loading

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 after idle.shutdownAfterMs as today. Both act on ready devices under their own claims; the pool's cleanup claim 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 no lastLeaseEndedAt; 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 after idle.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 is taken for 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 after endMaintenance finds no kind; devices shut down under nuke stay down (F13).
  • The registry's own state.db transactions: 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.ts and passed in the view; evaluate stays 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.kinds counts 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), newest last_leased_at first, 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.learn keeps 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).
  • LearnedKinds survives a reopen of the same file, and forget and clear remove the rows (cases: forget one of two; clear).
  • warmPoolOwner version 1 creates both STRICT tables with the columns above (extends the owner conformance the state module gives, if any; else a direct sqlite_master read).
  • evaluate fills 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, origin configured, count 2, configuredCount 1, one proposal, in the configured position).
  • evaluate keeps a learned kind's never-leased device out of never-leased-idle while the kind wants it, and proposes one beyond its count after idle.shutdownAfterMs (extends "shuts a never-leased device no target counts down ...").
  • evaluate displaces: a learned kind short for a running slot gets the LRU idle ready device of an older learned kind shut down with reason displaced and 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 for ram-budget, device-limit or reserve, or for a configured target (one case each).
  • evaluate proposes no recently-released boot: 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 ...").
  • targetedDevices lists a learned kind's ready devices up to its count, and WarmPool.targeted() includes them before the first pass and after a restart (converger.test.ts; reaper.test.ts already proves the reaper spares whatever the set names).
  • WarmPool learns on lease.granted the leased device's kind, closes it on lease.released and lease.expired, skips a physical device and learns nothing with warmPool.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 two WarmPools).
  • WarmPool starts 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 in models, whose OS is not in modelRuntimes[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; the listCatalog call 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).
  • WarmPool emits warm-pool.target-missed for a merged entry by its configured count: a configured count 1 met with a learned count 2 short emits nothing, and ready 0 emits with count: 1 (extends the target-missed tests).
  • WarmPool logs a LearnedKinds throw once and runs the pass with no learned kinds; start() survives a closeOpen throw, and the next pass calls closeOpen again and closes the rows once it succeeds.
  • WarmPool forgets 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).
  • NukeService clears the record before it resets devices, and a nuke whose forget throws still resets them (nuke-service.test.ts, F11f).
  • e2e (fake driver, knownModels and availableOsVersions scripted 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 granted source: warm; then withConfig({ 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); after simlock nuke --yes and 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; with warmPool.enabled set 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 (real simctl and emulator, F11g, F11i).

Done when

  • pnpm test:changed passes.
  • pnpm test:e2e e2e/warm-pool-learned.test.ts e2e/warm-pool-reserve.test.ts passes.
  • rg -n "recently-released" src docs --glob '!docs/internal/adr/**' finds nothing.
  • Needs real devices: scripts/slow-e2e.sh e2e/slow-warm-pool.test.ts passes its new "with no target" case.

Out of scope

  • Learned kinds in status, the console and doctor, and the origin field (the last task).
  • Fleet-level learning on the gateway.
  • Any change to lease.identity: a learned kind under fresh pre-creates as a configured target does.

Depends on

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

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 20, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 20, 2026
  3. self-assigned this
    on Oct 20, 2026
  4. 25 remaining items

  5. V3RON commented on Oct 21, 2026

    @V3RON
    ContributorAuthor

    Handoff

    Done: PR #792 (branch task/776, head 851801f, stacked on task/774) builds every Tests line in three build runs and updates the docs. The slow-lane case "with no target" in e2e/slow-warm-pool.test.ts is written, not run. Six assumptions in the PR body, including no new createCore catalog option.

    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.ts and nuke-service.ts remain. No review yet. CI has not run on 851801f.

    Findings: src/agent-device/host/download-cap.test.ts hangs locally (untouched by this branch). The "a new 3 GiB device fits" check in create-leasing.test.ts was 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.

  6. V3RON commented on Oct 21, 2026

    @V3RON
    ContributorAuthor

    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.

  7. added 2 commits that reference this issue on Oct 21, 2026
    af224ef
    e5ae89b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions