Skip to content

feat(capacity): make the concurrency policy a pluggable strategy - #64

Merged
V3RON merged 3 commits into
mainfrom
feat/capacity-strategies
Aug 31, 2026
Merged

feat(capacity): make the concurrency policy a pluggable strategy#64
V3RON merged 3 commits into
mainfrom
feat/capacity-strategies

Conversation

@V3RON

@V3RON V3RON commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes #63

Problem

How many devices may exist and run at once was decided in one place --
src/core/capacity.ts -- which hardcoded a single policy: device-count limits
plus a RAM budget, with the limits themselves derived from CPU and RAM.

Pinning a plain number was almost possible: you could set limits.maxRunning,
limits.ios.* and limits.android.*. But that is four coordinated keys, and the
RAM gate still sat underneath and could refuse provisioning below the pinned
number on a small machine. The pin was not authoritative, and there was no seam
for a third policy.

What changed

Capacity is now a CapacityStrategy behind one interface. Each strategy lives in
its own directory with a single entry point and is registered in one map, from
which the config type, its validation, and its defaults are all derived -- so
adding a policy touches neither CapacityCoordinator nor its callers.

src/core/capacity/
  strategy.ts              CapacityStrategy + CapacityStrategyDefinition
  limits.ts                shared running/device-ceiling math
  coordinator.ts           CapacityCoordinator (holds a strategy, not a Config)
  contract.test.ts         suite every registered strategy must pass
  strategies/
    index.ts               the registry
    resource/index.ts      today's policy, unchanged, still the default
    fixed/index.ts         a pinned number, no machine inspection

Two strategies ship:

  • resource -- today's behaviour, unchanged, still the default.
  • fixed -- a pinned number with no RAM budget and no CPU/RAM-derived
    defaults. maxRunning alone is a complete configuration; the per-platform
    blocks only carve that budget up.

CapacityCoordinator keeps all the reservation accounting and gains no knowledge
of which strategy it holds. daemon/server.ts stops reaching into
config.limits for its displayed device ceiling and asks the strategy, so the
reported number matches whichever policy is live.

Config

capacity is a discriminated union on strategy, with the strategy's own
options under capacity.config:

// resource (default)
{ "capacity": { "strategy": "resource", "config": { "limits": {...}, "ramBudget": {...} } } }

// fixed -- pinning is one key
{ "capacity": { "strategy": "fixed", "config": { "maxRunning": 4 } } }

Validation is "hand capacity.config to the selected strategy's validator", so
adding a strategy touches zero lines of the config type.

loadConfig gains one step: resolve capacity.strategy across the layers first
(last layer that names one wins, else resource), pull that strategy's
defaults from the registry, then merge. Everything outside capacity keeps using
the existing generic deep merge, untouched.

Backward compatibility

The pre-existing top-level limits and ramBudget keys are resource-shaped by
definition, so they fold into capacity.config whenever the resolved strategy is
resource -- which includes every config file written to date, since none of
them set capacity.strategy. Existing files keep working silently and
unchanged, with identical behaviour and identical defaults.
Nothing is
deprecated and no warning is emitted.

  • Legacy keys are normalized per layer, before merging, so precedence still
    holds regardless of which spelling each layer uses.
  • Within one layer, capacity.config wins over the legacy spelling.
  • Legacy keys keep their current validators, so typos in them are still reported.
  • Explicitly selecting a non-resource strategy and setting legacy keys is the
    one case that warns, since those settings would have no effect.
  • ConfigOverrides accepts both spellings, so daemon/main.ts and other
    programmatic callers are unaffected.
  • simlock config get/set are generic dotted-path accessors and needed no change.

Tests

  • contract.test.ts -- behaviour every strategy owes its callers, driven off the
    registry, so a new strategy is enrolled automatically.
  • Per-strategy suites for resource (the former capacity.test.ts, adapted) and
    fixed.
  • New config.test.ts coverage for strategy selection, per-strategy defaults and
    validation, and every backward-compatibility rule above.
  • The e2e lane deliberately still writes the old spelling throughout, so it
    doubles as end-to-end coverage that an older config file configures the
    resource strategy correctly. Noted in e2e/helpers/env.ts.

680 unit tests pass. e2e: 35 passed, 1 failure (slow-android-smoke, a stray
daemon process on teardown in the real-emulator lane) which reproduces on main
and is unrelated to this change.

Docs

docs/CONFIGURATION.md gains a "Capacity strategies" section with per-strategy
option tables and an "Older config files" note; docs/ARCHITECTURE.md describes
the strategy seam; docs/CLI.md points simlock config at the new section.

https://claude.ai/code/session_015M1UR25DZcAxQeYUuEhLZ3

V3RON added 3 commits August 21, 2026 19:01
Publish only dist (minus tests and dist/e2e) plus README/LICENSE via
the files field, instead of the whole repo. Also add the missing
#!/usr/bin/env node shebang to cli/main.ts, without which the
installed bin was not directly executable.

Claude-Session: https://claude.ai/code/session_01DFg6QoHAJq5sLXm2vDwcxW
How many devices may exist and run at once was decided in one place that
hardcoded a single policy: device limits plus a RAM budget, with the limits
themselves derived from CPU and RAM. Pinning a plain number was possible only
by setting four coordinated keys, and the RAM gate still sat underneath and
could refuse below the pinned number.

Capacity is now a `CapacityStrategy` behind one interface. Each strategy lives
in its own directory with a single entry point and is registered in one map,
from which the config type, its validation, and its defaults are all derived --
so adding a policy touches neither `CapacityCoordinator` nor its callers.

Two ship:

- `resource` -- today's behaviour, unchanged, still the default.
- `fixed` -- a pinned number with no machine inspection at all. `maxRunning`
  alone is a complete configuration.

Config gains a `capacity` namespace discriminated on `strategy`, with the
strategy's own options under `capacity.config`. The pre-existing top-level
`limits` and `ramBudget` keys are normalized into it per layer, before merging,
so layer precedence is unaffected by which spelling each layer uses. Existing
config files keep working silently and unchanged; the e2e lane deliberately
stays on the old spelling to cover that path end to end.

Closes #63

Claude-Session: https://claude.ai/code/session_015M1UR25DZcAxQeYUuEhLZ3
@V3RON
V3RON merged commit b949b9e into main Aug 31, 2026
5 checks passed
@V3RON
V3RON deleted the feat/capacity-strategies branch August 31, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support pluggable capacity strategies (resource heuristics vs. fixed number)

1 participant