Skip to content

Commit aa30cb0

Browse files
authored
feat(serve): only offer machines that aren't busy (#2838)
1 parent d13ef06 commit aa30cb0

21 files changed

Lines changed: 1312 additions & 38 deletions

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
- Coordinator: remove per-owner lease lifetime overrides from the production configuration; every owner uses the global lease policy, and the deploy guard now protects the complete live override document generically. [PR 2830](https://github.com/openclaw/crabbox/pull/2830).
88

9+
### Added
10+
11+
- Serve: offer machines only while host CPU, memory, user activity, and power policies allow, with availability reasons, portal measurements, and a read-only local probe; running leases continue.
12+
913
### Fixed
1014

1115
- Release verification: accept provenance schema 4 (egress runtime packs) in the installed-Homebrew verifier, checking it against the same exact filesystem-layout archive inventory as schema 3. [PR 2840](https://github.com/openclaw/crabbox/pull/2840).

‎docs/commands/serve.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ crabbox serve --cpus 24 --memory 48g
99
crabbox serve --runtime podman --cpus 8 --memory 16g --advertise-address 100.64.1.2
1010
crabbox serve machines --json
1111
crabbox serve status --json
12+
crabbox serve status --probe --json
1213
crabbox serve cache prune
1314
crabbox serve install --cpus 24 --memory 48g
1415
crabbox serve uninstall
@@ -28,6 +29,13 @@ crabbox serve uninstall
2829
| `--state-dir` | Private machine identity/status directory |
2930
| `--no-cache-volumes` | Disable managed cache volumes |
3031
| `--drain-on-exit` | Remove containers on orderly agent shutdown |
32+
| `--availability` | `auto` (default) or `always` to disable availability gating |
33+
| `--max-host-cpu` | Maximum non-serve host CPU fraction, default `0.5` |
34+
| `--require-user-idle` | Minimum console idle duration, default `0` on desktops or `10m` on laptops |
35+
| `--require-ac-power` | Require AC on laptops, default `true`; ignored on desktops |
36+
| `--max-memory-pressure` | Busy at `warn` or `critical`, default `critical` |
37+
| `--probe` | Read-only local availability sampling (`status` only); no coordinator needed |
38+
| `--probe-duration` | Probe observation period, `1m`–`1h`, default `7m` |
3139

3240
`CRABBOX_SERVE_MEMORY` supplies the memory default. `serve.advertiseAddresses`
3341
or comma-separated `CRABBOX_SERVE_ADVERTISE_ADDRESSES` configures explicit
@@ -51,8 +59,9 @@ for details, fallback behavior, and image evidence.
5159
Each poll starts at least `--poll-interval` after the prior poll start and heartbeat response.
5260

5361
`status` checks coordinator access and reads the local machine UUID, last successful coordinator contact,
54-
active assignments, caps, and draining state. `machines` queries owner-visible
55-
coordinator inventory. Both support `--json`.
62+
active assignments, caps, draining state, and availability with reasons. `machines` queries owner-visible
63+
coordinator inventory. Both support `--json`. See [availability](../providers/serve.md#availability) for
64+
signals, configuration, laptop defaults, and hysteresis.
5665

5766
`install` writes and loads a per-user LaunchAgent on macOS or a systemd user
5867
unit on Linux, preserving the supplied flags. It uses the current executable

‎docs/features/portal.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,11 @@ dedicated host opens the matching visibility-only detail page. Unattached host
187187
details and host-pinned launch commands require admin authentication.
188188

189189
Owners enabled for [serve](../providers/serve.md) also see a **Machines** section
190-
below the fleet and a **machines online** readout when they have donated machines.
191-
It shows connection and draining status, last heartbeat age, used/offered and free
192-
CPU and memory, allocated lease links and limits, runtime and agent versions,
190+
below the fleet and a **machines available** readout when they have donated machines.
191+
It counts online, non-draining machines whose availability permits new leases.
192+
The section shows availability and reasons, measured host CPU, console idle time,
193+
power source, connection and draining status, last heartbeat age, used/offered and
194+
free CPU and memory, allocated lease links and limits, runtime and agent versions,
193195
labels, and the advertised SSH front door. Search and status filters apply only
194196
to this section. Machine visibility is owner- and organization-scoped, including
195197
for admins; disabled owners and owners without machines see no serve section.

‎docs/providers/serve.md‎

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ public lease keys, deadlines, and status, outside the repository.
7070
Every container gets a hard CPU limit, memory limit, and low CPU shares. The
7171
agent and coordinator enforce the offered CPU, memory, and `--max-leases` caps.
7272
CPU shares reduce contention but do not reserve idle CPU for the host.
73+
Availability sampling starts after the first reconciliation and image-preparation
74+
kickoff, and reuses reconciliation's container inventory. Busy or unknown
75+
availability does not delay image preparation or existing lease deadlines.
7376
The agent starts only its configured local-container image; a different image
7477
in an assignment fails with `serve_image_mismatch`.
7578
Containers have no host Docker socket, privileged mode, host network, or host
@@ -108,6 +111,69 @@ is retained for run reporting and cleanup. Fixed-ID acquisition does not switch
108111
providers. `CRABBOX_SERVE_MACHINE` and `CRABBOX_SERVE_FALLBACK` provide environment
109112
overrides.
110113

114+
## Availability
115+
116+
By default, a machine accepts new leases only while the owner is not using it.
117+
The agent samples host signals approximately every ten seconds, independently
118+
of heartbeats and deadline enforcement. Busy or unknown machines advertise zero
119+
free capacity; existing leases continue at their existing low CPU priority.
120+
Availability never kills a running lease.
121+
122+
| Policy | Desktop/server default | Laptop default (battery detected) |
123+
| --- | --- | --- |
124+
| `--max-host-cpu` | `0.5` | `0.5` |
125+
| `--require-user-idle` | `0` (disabled) | `10m` |
126+
| `--require-ac-power` | Ignored | `true` |
127+
| `--max-memory-pressure` | `critical` | `critical` |
128+
129+
CPU is a time-weighted 60-second average of total host utilization minus this
130+
agent's owned, labeled containers' runtime CPU usage, normalized by host logical
131+
cores and clamped at zero. On macOS the agent uses
132+
Mach host CPU counter deltas (or interval `top` samples in builds without cgo), the kernel memory-pressure
133+
level, IOHIDSystem console idle time, and `pmset` power information. On Linux it
134+
uses `/proc/stat`, `MemAvailable` (warning below 10%, critical below 5%), and
135+
sysfs power information. Linux console idle time is unknown: enabling an idle
136+
requirement there conservatively prevents availability. Unsupported or failed
137+
required measurements are unknown, never treated as idle. Unknown form factor
138+
uses laptop defaults.
139+
140+
CPU above the threshold, memory pressure at or above the selected level, or
141+
battery power must persist for at least 60 seconds before becoming busy.
142+
Recent console activity makes the machine busy immediately. All required
143+
conditions must be clear for five minutes before availability returns, including
144+
at startup after the first complete CPU window. Unknown measurements immediately
145+
prevent new placement. `--availability always` explicitly disables gating.
146+
147+
Configuration uses `serve.availability.mode`, `maxHostCPU`, `requireUserIdle`,
148+
`requireACPower`, and `maxMemoryPressure`. Flags override environment variables,
149+
which override configuration and detected defaults. The corresponding environment
150+
variables are `CRABBOX_SERVE_AVAILABILITY`, `CRABBOX_SERVE_MAX_HOST_CPU`,
151+
`CRABBOX_SERVE_REQUIRE_USER_IDLE`, `CRABBOX_SERVE_REQUIRE_AC_POWER`, and
152+
`CRABBOX_SERVE_MAX_MEMORY_PRESSURE`.
153+
154+
```yaml
155+
serve:
156+
availability:
157+
mode: auto
158+
maxHostCPU: 0.5
159+
requireUserIdle: 10m
160+
requireACPower: true
161+
maxMemoryPressure: critical
162+
```
163+
164+
`crabbox serve status` and `crabbox serve machines` show availability and reasons.
165+
The portal shows the same state, CPU, console idle time, and power source; its
166+
machine tile counts online, non-draining available machines. Legacy agents
167+
without availability data retain their previous placement behavior. Capacity
168+
errors include owner-visible machine states and reasons.
169+
170+
For a read-only local measurement, run `crabbox serve status --probe --json`.
171+
It samples for seven minutes by default to include the startup and recovery windows,
172+
without registering, changing services, starting containers, or writing state.
173+
`--probe-duration 1m` through `1h` changes the observation period; a short probe
174+
may correctly report unknown while warming up. Use the agent's `--state-dir`
175+
and `--runtime` to subtract its own containers.
176+
111177
## Prepared container images
112178

113179
After the first heartbeat and reconciliation, Serve probes the configured image
@@ -189,8 +255,8 @@ in 1–65535. The agent binds each advertised IPv4 address separately on that
189255
same port, never a wildcard address. If some binds fail, it logs the failures
190256
and advertises only the addresses that bound; no bound addresses is fatal.
191257
The agent re-detects candidates on each heartbeat and rebinds when addresses
192-
change, including DHCP and Wi-Fi changes. Capacity is controlled solely by
193-
`--max-leases` and the CPU/memory offer.
258+
change, including DHCP and Wi-Fi changes. Capacity is controlled by availability,
259+
`--max-leases`, and the CPU/memory offer.
194260

195261
Automatic LAN candidates come from up, non-loopback, broadcast-capable
196262
interfaces in `10/8`, `172.16/12`, or `192.168/16`. Tunnel and runtime bridge

‎internal/cli/config.go‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,11 +1743,20 @@ func baseConfig() Config {
17431743
}
17441744

17451745
type ServeConfig struct {
1746-
AdvertiseAddresses []string `yaml:"advertiseAddresses,omitempty"`
1747-
NoLANCandidates bool `yaml:"noLANCandidates,omitempty"`
1748-
SSHPort int `yaml:"sshPort,omitempty"`
1749-
Machine string `yaml:"machine,omitempty"`
1750-
Fallback string `yaml:"fallback,omitempty"`
1746+
Availability ServeAvailabilityConfig `yaml:"availability,omitempty"`
1747+
AdvertiseAddresses []string `yaml:"advertiseAddresses,omitempty"`
1748+
NoLANCandidates bool `yaml:"noLANCandidates,omitempty"`
1749+
SSHPort int `yaml:"sshPort,omitempty"`
1750+
Machine string `yaml:"machine,omitempty"`
1751+
Fallback string `yaml:"fallback,omitempty"`
1752+
}
1753+
1754+
type ServeAvailabilityConfig struct {
1755+
Mode string `yaml:"mode,omitempty"`
1756+
MaxHostCPU *float64 `yaml:"maxHostCPU,omitempty"`
1757+
RequireUserIdle string `yaml:"requireUserIdle,omitempty"`
1758+
RequireACPower *bool `yaml:"requireACPower,omitempty"`
1759+
MaxMemoryPressure string `yaml:"maxMemoryPressure,omitempty"`
17511760
}
17521761

17531762
type fileConfig struct {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package localcontainer
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"math"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
// CPUCores reports runtime CPU usage in host-core units: 100% equals one core.
12+
// The caller supplies only its owned running containers, never a runtime-wide total.
13+
func (r *ServedRuntime) CPUCores(ctx context.Context, containers []ServedContainer) (float64, error) {
14+
if len(containers) == 0 {
15+
return 0, nil
16+
}
17+
args := []string{"stats", "--no-stream", "--no-trunc", "--format", "{{.ID}} {{.CPUPerc}}"}
18+
ids := map[string]bool{}
19+
for _, c := range containers {
20+
ids[c.ID] = true
21+
args = append(args, c.ID)
22+
}
23+
result, err := r.b.docker(ctx, args, nil, nil)
24+
if err != nil {
25+
return 0, err
26+
}
27+
total := 0.0
28+
for _, line := range strings.Split(strings.TrimSpace(result.Stdout), "\n") {
29+
fields := strings.Fields(line)
30+
if len(fields) != 2 || !ids[fields[0]] || !strings.HasSuffix(fields[1], "%") {
31+
return 0, fmt.Errorf("invalid owned-container CPU stats")
32+
}
33+
value, err := strconv.ParseFloat(strings.TrimSuffix(fields[1], "%"), 64)
34+
if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
35+
return 0, fmt.Errorf("invalid owned-container CPU usage")
36+
}
37+
total += value / 100
38+
delete(ids, fields[0])
39+
}
40+
if len(ids) > 0 {
41+
return 0, fmt.Errorf("missing owned-container CPU stats")
42+
}
43+
return total, nil
44+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package localcontainer
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"reflect"
7+
"testing"
8+
9+
core "github.com/openclaw/crabbox/internal/cli"
10+
)
11+
12+
type statsRunner struct {
13+
output string
14+
args []string
15+
}
16+
17+
func (r *statsRunner) Run(_ context.Context, req core.LocalCommandRequest) (core.LocalCommandResult, error) {
18+
r.args = req.Args
19+
return core.LocalCommandResult{Stdout: r.output}, nil
20+
}
21+
func TestServedCPUCores(t *testing.T) {
22+
for _, tc := range []struct {
23+
output string
24+
want float64
25+
bad bool
26+
}{
27+
{"owned-a 150.00%\nowned-b 50.00%", 2, false},
28+
{"owned-a 0.00%\nowned-b 0.00%", 0, false},
29+
{"unowned 900.00%", 0, true}, {"owned-a 10.00%", 0, true},
30+
{"owned-a NaN%\nowned-b 0%", 0, true}, {"owned-a -1%\nowned-b 0%", 0, true},
31+
} {
32+
t.Run(fmt.Sprint(tc.output), func(t *testing.T) {
33+
runner := &statsRunner{output: tc.output}
34+
rt := NewServedRuntime(core.Config{LocalContainer: core.LocalContainerConfig{Runtime: "docker"}}, core.Runtime{Exec: runner})
35+
got, err := rt.CPUCores(t.Context(), []ServedContainer{{ID: "owned-a"}, {ID: "owned-b"}})
36+
if (err != nil) != tc.bad || got != tc.want {
37+
t.Fatalf("got %v %v", got, err)
38+
}
39+
if !reflect.DeepEqual(runner.args, []string{"stats", "--no-stream", "--no-trunc", "--format", "{{.ID}} {{.CPUPerc}}", "owned-a", "owned-b"}) {
40+
t.Fatalf("unscoped stats: %v", runner.args)
41+
}
42+
})
43+
}
44+
}

0 commit comments

Comments
 (0)