web: auto-size a handed-off task's cloud machine (fleet sizing model)
Leo: "You know how we did that automatic routing, automatic sizing, automatic, you know, picking the best machine type logic for stuff? We want to have that for the cloud machines too, and just getting that cloud stuff just going, just simple, easy. Like ChatGPT work."
The spec: the user never picks a machine type. A handed-off task's size comes from what it needs (repo size, build/test needs, past measured runs of the same repo and task kind, a GPU only if asked), shown as one line ("4 vCPU 路 16 GB, picked from 3 earlier runs") with an override, never a form. It reuses the fleet's sizing model; it is not a second design.
What this does
web/services/vms/autosize.ts: a pure picker,pickMachineSize({taskKind, repo: {sizeMb, rootFiles}, gpu, measuredRuns, planMemoryOptionsMb}). It returns the shape plus{vcpus, memoryGb, pick, reason}, which are the hand-off store's size fields (POST /v1/handoffssize, #26578).- The fleet's model. A task's need is the fleet's measured grant for the same work: memory from
ci_step_mem.go(p95 of measured peak growth plus 20%) and cores fromci_step_cpu.go(measured core use per step). Once runs are measured, they replace the table by the controller's rules. Memory and disk are p95 of the peaks plus 20%, rounded up to whole GiB (ci_step_mem.go). Cores are p90 of each run's mean busy cores, rounded up, with no margin (ci_step_cpu.go: the Release compile's p90 of 3.26 cores is its 4-core demand). - One source of truth for the data. Needs, toolchain markers and the margin are declarative tables. Shapes come only from the existing ladder (
images/sizes.ts), cut to what the plan allows (memoryOptionsMbForPlan). The picker takes the smallest shape that covers memory, cores and disk. If no allowed shape covers the need, it takes the plan's largest and the reason says so. - GPU is refused (
vm_gpu_unavailable) whatever else the request says, because no provider or contract has a GPU dimension yet.
- The fleet's model. A task's need is the fleet's measured grant for the same work: memory from
- Wired into
POST /api/vm, the create the app's Cloud machines use. It takes a new optionalautosize: {taskKind, repo?, gpu?}. When there is nomemoryMborimage(both stay the override, since an image boots at its own snapshot's size) and the provider sizes its machines, the server picks the memory. The answer carriessizePickonly when the machine boots at the picked snapshot. Withoutautosize, nothing changes.
Shape table (the existing ladder, which the picker reads):
| pick | vCPU | memory | disk |
|---|---|---|---|
| sm | 2 | 4 GB | 16 GB |
| md | 4 | 8 GB | 32 GB |
| lg | 8 | 16 GB | 64 GB |
| lgx | 12 | 24 GB | 96 GB |
| xl | 16 | 32 GB | 128 GB |
| 2xl | 32 | 64 GB | 128 GB |
Needs table (on top of the agent session's 2 GB / 2 cores, the fleet's light-step grant):
| task | Swift | Rust | Node | other |
|---|---|---|---|---|
| chat | +0 | +0 | +0 | +0 |
| build | 13 GB, 4 cores (release compile) | 15 GB, 8 (rust check) | 2 GB, 6 (webview tests) | 8 GB, 8 (isolated step) |
| test | 11 GB, 6 (Swift suites) | 15 GB, 8 | 2 GB, 6 | 8 GB, 8 |
Example picks:
- A Swift build on Pro gets
lg: "8 vCPU 路 16 GB, picked for a Swift build". - The same build after three runs that peaked at 5.0, 6.0 and 7.0 GB gets
lg: "8 vCPU 路 16 GB, picked from 3 earlier runs". - After one run that peaked at 3 GB it gets
sm: "2 vCPU 路 4 GB, picked from 1 earlier run". - A run that ran short (
ranShort) is a floor: the next pick is above that run's shape, whatever the other runs' p95 says.
Not in this PR
- Measured runs are passed as
[]for now. The usage signal exists: the guest reporter posts CPU, memory and disk every 30 s. But only the latest sample is kept, so there are no peaks per run and no repo or task identity on the VM row. The follow-up PR keeps running peaks per VM inrecordResourceUsage, tags the VM with its repo and task kind at create, and feeds the newest 20 matching runs to the picker. - Resize-up when short (design only). The Freestyle path already supports a grow-only resize (
POST /api/vm/:id/resize,resizeVmin workflows.ts). It should work like this:- At a turn boundary, the machine's sampled memory or disk passes 90% of its shape.
- The hand-off asks for the next shape up through that same resize. GCP has no resize and keeps its shape.
- The run is then recorded as having run short (
MeasuredRun.ranShort), so the next pick of that repo and task starts above the shape it had.
- The one-line pick in the chat header with an override menu belongs to the composer hand-off (cloud work mode build order, item 4). It reads
sizePick.reason.
Risk
Low. The new path runs only when a caller sends autosize; no current client does. A pick stays within the plan's sizes and goes through the existing resolveCreateMemory, so plan caps, locked sizes and idempotency are unchanged. A provider without sizing (GCP) gets no pick. The one reorder is that the provider id is now resolved before memory, still after the paid-plan admission, and it is a pure lookup.
Checks: bun test (vm-autosize, vm-image-sizes, vm-create-option-policy, vm-route-auth, with new route tests for the pick, an explicit image, and a GPU beside memoryMb: 142 pass), tsgo --noEmit, eslint and the complexity gate, all on a Blacksmith Linux fast-check run.
馃 Generated with Claude Code
Agent session: agent session