From 10a269d150f8045ed90938aa8a69face945ba33a Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:40:36 -0700 Subject: [PATCH 1/2] feat(park): berth unpark and a park on/off switch berth unpark ENGINE|--all evicts parked engines from RAM now (running ones are untouched; nothing parked is no error). berth park off|on|status switches RAM parking off for every card, evicting what is parked, and back on; status carries park_off. The switch lives in the daemon. --- CHANGELOG.md | 5 ++ docs/gateway.md | 20 +++++ internal/broker/broker.go | 1 + internal/broker/park.go | 5 ++ internal/broker/status.go | 1 + internal/broker/unpark.go | 64 +++++++++++++++ internal/broker/unpark_test.go | 99 +++++++++++++++++++++++ internal/cli/commands.go | 26 ++++++ internal/cli/unpark.go | 141 +++++++++++++++++++++++++++++++++ internal/cli/unpark_test.go | 57 +++++++++++++ internal/node/node.go | 33 ++++++++ internal/proto/proto.go | 15 ++++ internal/server/park.go | 11 +++ internal/server/server.go | 4 + 14 files changed, 482 insertions(+) create mode 100644 internal/broker/unpark.go create mode 100644 internal/broker/unpark_test.go create mode 100644 internal/cli/unpark.go create mode 100644 internal/cli/unpark_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c4d6c..53b170b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ has a matching GitHub release whose body comes from its section below. ### Added +- `berth unpark ENGINE` and `berth unpark --all`: drop a parked engine from RAM now instead of at its ttl, for a job + that needs the machine's RAM. Only parked engines are touched; nothing parked is no error. `berth park off|on|status` + switches RAM parking off for every card (evicting what is parked) and back on; `berth status` carries `park_off` + while it is off. The switch lives in the daemon, so a restart starts with parking on. docs/gateway.md has the section. + - `needs_mib = [18000, 6000]` on an `[[engine]]` entry with `gpu = "gpu0,gpu1"`, and `berth run --needs-mib 18000,6000`: a size per card, in the order the cards are named, for a model split unevenly across cards (llama.cpp `--tensor-split`). Each card's share of the lease reserves its own size, so the room left on the lighter card can diff --git a/docs/gateway.md b/docs/gateway.md index c19cdb5..d04c90e 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -676,6 +676,26 @@ pressure = 10 # PSI "some avg10" that stops one parked copy (default 10 `berth stop` or an eviction therefore kills it at once, so a parked engine never stays alive holding its weights in RAM after llama-swap unloads it. +### Unpark now, or switch parking off + +Parking is automatic, but you decide when RAM matters more than a warm switch: + +```bash +berth unpark ninfer # drop one parked engine from RAM now (by name or family) +berth unpark --all # drop every parked engine +berth park off # stop parking: evicts what is parked, every later drain stops its engine +berth park on # parking back on +berth park # the switch, as now (--format json for scripts) +``` + +`berth unpark` only touches parked engines: one that is running on the card is +`berth stop`'s. Each evicted engine stops through its own wrapper, as a ttl +eviction stops it, and the next request starts it cold. Nothing parked is no +error, so a long job that needs the machine's RAM (a two-hour ComfyUI video, +say) can run `berth unpark --all` before it starts. The switch lives in the +daemon: a restart of berthd starts with parking on. `berth status --format json` +carries `park_off` while it is off. + ## Engines that need the whole machine Some engines use more than the card. Strata fills the card and pins 40 GiB of RAM for its experts, and it diff --git a/internal/broker/broker.go b/internal/broker/broker.go index 44478a5..13c45f6 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -486,6 +486,7 @@ type Broker struct { verdicts map[string]verdict // loser engine key -> the juggle it lost cordoned bool // berth cordon: grant only holds + parkOff bool // berth park off: drains stop their engine, nothing parks // tracked is the claim watcher's last scan: a process (and every child) // a track claim names. Its card memory is the claim's, never a stowaway. tracked map[int]string diff --git a/internal/broker/park.go b/internal/broker/park.go index e565925..d19c086 100644 --- a/internal/broker/park.go +++ b/internal/broker/park.go @@ -175,6 +175,11 @@ func (b *Broker) Park(id string) (bool, string) { if !ok || l.state != draining { return false, fmt.Sprintf("lease %s is not draining", id) } + if b.parkOff { + why := "park refused: parking is switched off (berth park on turns it back on)" + b.emit(proto.Event{Kind: proto.EvParkRefused, Lease: id, Engine: l.engine, Detail: why}) + return false, why + } if b.snap.APU { why := "park refused: APU VRAM/GTT offload accounting is not yet supported; using normal drain" b.emit(proto.Event{Kind: proto.EvParkRefused, Lease: id, Engine: l.engine, Detail: why}) diff --git a/internal/broker/status.go b/internal/broker/status.go index 93756f9..bf93210 100644 --- a/internal/broker/status.go +++ b/internal/broker/status.go @@ -47,6 +47,7 @@ func (b *Broker) Status() proto.Status { Priority: b.priorityStatus(now), Footprints: b.footprintStatus(), ParkedBytes: b.parkedRAM(), + ParkOff: b.parkOff, } if b.probeErr != nil { st.ProbeErr = b.probeErr.Error() diff --git a/internal/broker/unpark.go b/internal/broker/unpark.go new file mode 100644 index 0000000..25c727d --- /dev/null +++ b/internal/broker/unpark.go @@ -0,0 +1,64 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package broker + +// Unpark and the park switch: a person's own say over RAM parking. Parking +// stays automatic; berth unpark drops a parked engine from RAM now, and +// berth park off stops parking altogether (every drain stops its engine, as +// before parking existed) until berth park on. The switch lives in the +// daemon: a restart starts with parking on. + +import ( + "sort" +) + +// Unpark evicts the parked and parking leases of engine, by its name or +// its group ("" = every one): each stops the way a ttl eviction stops it, +// through its own runner, and the RAM it held is freed. The reply names +// the leases asked; none parked is no error, so a job can unpark before it +// starts without first asking what is parked. +func (b *Broker) Unpark(engine string) []string { + b.mu.Lock() + defer b.mu.Unlock() + return b.unparkLocked(engine, "unparked by berth unpark") +} + +// SetParkOff turns parking off (true) or back on (false) for this card. +// Turning it off also evicts every lease parked here now. Returns the +// leases evicted. +func (b *Broker) SetParkOff(off bool) []string { + b.mu.Lock() + defer b.mu.Unlock() + b.parkOff = off + if !off { + return nil + } + return b.unparkLocked("", "parking switched off by berth park off") +} + +// ParkOff is whether parking is switched off on this card. +func (b *Broker) ParkOff() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.parkOff +} + +// unparkLocked evicts the matching parked or parking leases. A parking +// lease hears the drain once its park call ends, as berth stop's does. +// Called with b.mu held. +func (b *Broker) unparkLocked(engine, why string) []string { + ids := []string{} + for _, l := range b.leases { + if l.state != parked && l.state != parking { + continue + } + if engine != "" && l.engine != engine && l.group != engine { + continue + } + b.evict(l, why) + ids = append(ids, l.id) + } + sort.Strings(ids) + return ids +} diff --git a/internal/broker/unpark_test.go b/internal/broker/unpark_test.go new file mode 100644 index 0000000..fb92056 --- /dev/null +++ b/internal/broker/unpark_test.go @@ -0,0 +1,99 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package broker + +// berth unpark and berth park off, through the lease table: unpark evicts +// only parked leases (by name or all), a running engine is never touched, +// park off refuses new parks and evicts what is parked, park on restores. + +import ( + "context" + "strings" + "testing" + + "github.com/T-Crypt/berth/internal/gpu" + "github.com/T-Crypt/berth/internal/proto" +) + +func drainReason(t *testing.T, l *Lease) string { + t.Helper() + select { + case why := <-l.Drains(): + return why + default: + t.Fatal("no drain asked of the parked lease") + } + return "" +} + +func TestUnparkEvictsTheNamedParkedEngine(t *testing.T) { + tree := parkTree(100, 101) + b, c, _ := parkSetup(t, tree, 32*GiB, 0) + l := warmLease(t, b, c, "ninfer", 8*GiB, 100, 101) + if got := b.Unpark("someone-else"); len(got) != 0 { + t.Fatalf("unpark of another engine = %v, want nothing", got) + } + got := b.Unpark("ninfer") + if len(got) != 1 || got[0] != l.ID() { + t.Fatalf("unpark = %v, want [%s]", got, l.ID()) + } + if why := drainReason(t, l); !strings.Contains(why, "unparked by berth unpark") { + t.Fatalf("drain reason = %q", why) + } + if st := b.Status(); st.Leases[0].State != "draining" { + t.Fatalf("state = %s, want draining", st.Leases[0].State) + } + evs, _, _ := b.Events(0, false) + if evs[len(evs)-1].Kind != proto.EvParkEvicted { + t.Fatalf("last event = %+v, want park_evicted", evs[len(evs)-1]) + } +} + +func TestUnparkLeavesARunningEngineAlone(t *testing.T) { + tree := parkTree(100, 101) + b, c, _ := parkSetup(t, tree, 32*GiB, 0) + l := must(t, acquire(b, Request{Engine: "comfyui", Needs: 8 * GiB})) + l.Attach(100) + c.set(gpu.Proc{PID: 101, Name: "comfyui-engine", Used: 8 * GiB}) + b.Tick(context.Background()) + if got := b.Unpark(""); len(got) != 0 { + t.Fatalf("unpark --all = %v, want nothing (the engine is active, not parked)", got) + } + select { + case why := <-l.Drains(): + t.Fatalf("active lease was asked to drain: %q", why) + default: + } +} + +func TestParkOffRefusesAndEvictsAndOnRestores(t *testing.T) { + tree := parkTree(100, 101) + b, c, _ := parkSetup(t, tree, 32*GiB, 0) + l := warmLease(t, b, c, "ninfer", 8*GiB, 100, 101) + got := b.SetParkOff(true) + if len(got) != 1 || got[0] != l.ID() || !b.ParkOff() || !b.Status().ParkOff { + t.Fatalf("park off = %v (off %v)", got, b.ParkOff()) + } + if why := drainReason(t, l); !strings.Contains(why, "parking switched off") { + t.Fatalf("drain reason = %q", why) + } + + // A new drain while parking is off is refused a park. + tree.Parents[102], tree.Parents[103] = 103, 1 + tree.Starts[102], tree.Starts[103] = 1, 1 + m := must(t, acquire(b, Request{Engine: "sglang", Needs: 4 * GiB})) + m.Attach(103) + c.set(gpu.Proc{PID: 102, Name: "sglang-engine", Used: 4 * GiB}) + b.Tick(context.Background()) + w := acquire(b, Request{Engine: "next2", Priority: Foreground}) + pending(t, w) + drained(t, m) + if ok, why := m.Park(); ok || !strings.Contains(why, "parking is switched off") { + t.Fatalf("park = %v %q, want refused while off", ok, why) + } + + if got := b.SetParkOff(false); len(got) != 0 || b.ParkOff() { + t.Fatalf("park on = %v (off %v)", got, b.ParkOff()) + } +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index fc217e1..cb6608f 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -381,6 +381,32 @@ signal and skips a mismatch, so a reused PID is never signalled.`, Formats: []Format{FormatHuman, FormatJSON, FormatYAML}, Run: runReclaim, }, + { + Name: "park", + Summary: "RAM parking: on, off, or status", + Args: "[on|off|status] [--socket P] [--format F]", + Help: `Parking is automatic: a drained engine that fits in RAM parks there, so it +comes back in seconds. berth park off switches it off for every card: it +evicts what is parked now, and every drain after it stops its engine the way +it did before parking existed. berth park on switches it back on. With no +argument, or status, it reports the switch. The switch lives in the daemon: +a restart of berthd starts with parking on.`, + Formats: []Format{FormatHuman, FormatJSON, FormatYAML}, + Run: runPark, + }, + { + Name: "unpark", + Summary: "drop a parked engine from RAM now", + Args: "ENGINE | --all [--socket P] [--format F]", + Help: `Evict the parked engine (by name or engine family), or every parked engine +with --all, so the RAM it holds is free now instead of at its ttl. Each one +stops through its own wrapper, as a ttl eviction stops it; the next request +for it starts it cold. An engine that is running on the card is not touched: +berth stop is for that. Nothing parked is no error, so a long job can run +berth unpark --all before it starts.`, + Formats: []Format{FormatHuman, FormatJSON, FormatYAML}, + Run: runUnpark, + }, { Name: "resume", Summary: "put a parked engine back on the card", diff --git a/internal/cli/unpark.go b/internal/cli/unpark.go new file mode 100644 index 0000000..952716e --- /dev/null +++ b/internal/cli/unpark.go @@ -0,0 +1,141 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package cli + +// berth unpark and berth park: a person's say over RAM parking. Parking +// stays automatic; unpark drops a parked engine from RAM now (a long render +// that wants the RAM runs it first), and park off stops parking until park +// on. + +import ( + "strings" + + "github.com/T-Crypt/berth/internal/proto" + "github.com/T-Crypt/berth/internal/server" +) + +func runUnpark(c *Context) int { + fs := c.FlagSet() + sock := fs.String("socket", defaultSocket(), "berthd socket ($BERTH_SOCKET)") + all := fs.Bool("all", false, "every parked engine") + if code, done := c.ParseInterspersed(fs); done { + return code + } + if (len(c.Args) == 1) == *all || len(c.Args) > 1 { + return c.fail(Failf("berth unpark takes one engine name, or --all"). + WithHint("usage: berth unpark ENGINE | berth unpark --all")) + } + engine := "" + if !*all { + engine = c.Args[0] + } + m, e := parkCall(*sock, proto.Msg{Op: proto.OpUnpark, Engine: engine}, proto.OpUnpark) + if e != nil { + return c.fail(e) + } + return c.Write(parkView{engine: engine, leases: nonNil(m.Leases), unpark: true}, nil) +} + +func runPark(c *Context) int { + fs := c.FlagSet() + sock := fs.String("socket", defaultSocket(), "berthd socket ($BERTH_SOCKET)") + if code, done := c.ParseInterspersed(fs); done { + return code + } + req := proto.Msg{Op: proto.OpParkMode} + switch { + case len(c.Args) == 1 && c.Args[0] == "off": + off := true + req.ParkOff = &off + case len(c.Args) == 1 && c.Args[0] == "on": + off := false + req.ParkOff = &off + case len(c.Args) == 0 || (len(c.Args) == 1 && c.Args[0] == "status"): + default: + return c.fail(Failf("berth park takes on, off or status").WithHint("usage: berth park [on|off|status]")) + } + m, e := parkCall(*sock, req, proto.OpParkMode) + if e != nil { + return c.fail(e) + } + v := parkView{leases: nonNil(m.Leases), asked: req.ParkOff != nil} + if m.ParkOff != nil { + v.off = *m.ParkOff + } + return c.Write(v, nil) +} + +// parkCall sends one request and reads its one reply. +func parkCall(sock string, req proto.Msg, want string) (proto.Msg, *Error) { + conn, raw, err := server.Dial(sock) + if err != nil { + return proto.Msg{}, Systemf("berthd unreachable on %s: %v", sock, err) + } + defer raw.Close() + if err := conn.Send(req); err != nil { + return proto.Msg{}, Systemf("berthd on %s: %v", sock, err) + } + m, err := conn.Recv() + if err != nil { + return proto.Msg{}, Systemf("no reply from berthd: %v", err) + } + if m.Op == proto.OpRefused { + return proto.Msg{}, Failf("%s", m.Reason) + } + if m.Op != want { + return proto.Msg{}, Systemf("unexpected reply from berthd: op %q (an older berthd? restart it after an update)", m.Op) + } + return m, nil +} + +func nonNil(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +type parkView struct { + engine string + leases []string + unpark bool // berth unpark, else berth park + asked bool // berth park on|off, else status + off bool +} + +func (v parkView) Data() any { + if v.unpark { + return struct { + Engine string `json:"engine,omitempty" yaml:"engine,omitempty"` + Evicted []string `json:"evicted" yaml:"evicted"` + }{v.engine, v.leases} + } + return struct { + Parking string `json:"parking" yaml:"parking"` + Evicted []string `json:"evicted" yaml:"evicted"` + }{map[bool]string{true: "off", false: "on"}[v.off], v.leases} +} + +func (v parkView) Human(c *Context, ok bool) error { + if !v.unpark { + if v.off { + c.Printf("%s parking is off: a drain stops its engine instead of parking it in RAM\n", c.Style.OK(markOK)) + if !v.asked { + c.Bullet("berth park on turns it back on") + } + } else { + c.Printf("%s parking is on: a drained engine parks in RAM when it fits\n", c.Style.OK(markOK)) + } + } + switch { + case len(v.leases) > 0: + c.Printf("evicted from RAM: %s\n", strings.Join(v.leases, ", ")) + c.Bullet("each stops through its own wrapper; its RAM frees as it exits") + case v.unpark && v.engine != "": + c.Printf("nothing parked for %s\n", v.engine) + case v.unpark: + c.Printf("nothing parked\n") + } + return nil +} diff --git a/internal/cli/unpark_test.go b/internal/cli/unpark_test.go new file mode 100644 index 0000000..03e1d10 --- /dev/null +++ b/internal/cli/unpark_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package cli + +// berth unpark and berth park against a live daemon: usage errors, nothing +// parked is no error, and the park switch round trip in human and JSON. + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestUnparkUsage(t *testing.T) { + isolate(t) + sock, _, _ := startStopDaemon(t, time.Hour) + for _, args := range [][]string{{"unpark"}, {"unpark", "a", "--all"}, {"unpark", "a", "b"}} { + if code, _, _ := runCLI(t, append(args, "--socket", sock)...); code != ExitFail { + t.Fatalf("%v exit = %d, want %d", args, code, ExitFail) + } + } +} + +func TestUnparkNothingParked(t *testing.T) { + isolate(t) + sock, b, c := startStopDaemon(t, time.Hour) + grantEngine(t, b, c, "ninfer") + code, out, errOut := runCLI(t, "unpark", "--all", "--socket", sock) + if code != ExitOK || !strings.Contains(out, "nothing parked") { + t.Fatalf("exit %d out %q err %q", code, out, errOut) + } +} + +func TestParkSwitchRoundTrip(t *testing.T) { + isolate(t) + sock, b, _ := startStopDaemon(t, time.Hour) + code, out, errOut := runCLI(t, "park", "off", "--socket", sock) + if code != ExitOK || !strings.Contains(out, "parking is off") || !b.ParkOff() { + t.Fatalf("park off: exit %d out %q err %q", code, out, errOut) + } + code, out, _ = runCLI(t, "park", "--socket", sock, "--format", "json") + var v struct { + Parking string `json:"parking"` + Evicted []string `json:"evicted"` + } + if code != ExitOK || json.Unmarshal([]byte(out), &v) != nil || v.Parking != "off" { + t.Fatalf("park status json: exit %d out %q", code, out) + } + if code, out, _ = runCLI(t, "park", "on", "--socket", sock); code != ExitOK || !strings.Contains(out, "parking is on") || b.ParkOff() { + t.Fatalf("park on: exit %d out %q", code, out) + } + if code, _, _ = runCLI(t, "park", "sideways", "--socket", sock); code != ExitFail { + t.Fatalf("bad arg exit = %d", code) + } +} diff --git a/internal/node/node.go b/internal/node/node.go index 0aba0fd..bd257bc 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -254,6 +254,38 @@ func (n *Node) Stop(engine string) ([]string, error) { return slices.Compact(ids), nil } +// Unpark evicts engine's parked leases ("" = every one) on every card. +func (n *Node) Unpark(engine string) []string { + ids := []string{} + for _, c := range n.cards { + ids = append(ids, c.B.Unpark(engine)...) + } + slices.Sort(ids) + return slices.Compact(ids) +} + +// SetParkOff switches parking off or on for every card; off evicts what +// is parked now. +func (n *Node) SetParkOff(off bool) []string { + ids := []string{} + for _, c := range n.cards { + ids = append(ids, c.B.SetParkOff(off)...) + } + slices.Sort(ids) + return slices.Compact(ids) +} + +// ParkOff is whether parking is switched off (any card counts: the switch +// is set on all of them at once). +func (n *Node) ParkOff() bool { + for _, c := range n.cards { + if c.B.ParkOff() { + return true + } + } + return false +} + // Resume puts engine's parked lease back in line on the card it parked on. func (n *Node) Resume(engine string, switched bool) error { var first error @@ -386,6 +418,7 @@ func (n *Node) Status() proto.Status { st.Priority = mergePriority(st.Priority, cs.Priority) st.Counters = add(st.Counters, cs.Counters) st.ParkedBytes += cs.ParkedBytes + st.ParkOff = st.ParkOff || cs.ParkOff if cs.ProbeErr != "" { probeErrs = append(probeErrs, n.cards[i+1].Name+": "+cs.ProbeErr) } diff --git a/internal/proto/proto.go b/internal/proto/proto.go index fa91943..3fe6471 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -46,6 +46,15 @@ const ( // higher-priority waiter would. The server answers once with the leases // it asked. OpStop = "stop" + // OpUnpark: client: evict the parked and parking leases of Engine (by + // name or group; "" = every one) so the RAM they hold is freed. The + // server answers once with the leases it evicted (none is no error). + OpUnpark = "unpark" + // OpParkMode: client: with ParkOff set, switch parking off (true, which + // also evicts every parked lease) or back on (false); without it, ask. + // The server answers once with the state in ParkOff and the leases it + // evicted. + OpParkMode = "park_mode" // OpCordon, OpUncordon: client: stop or resume new grants on the cards // Card names (as OpAcquire names them). A cordoned card keeps its // holders and still grants a hold. The server answers with the same op @@ -161,7 +170,10 @@ type Msg struct { Container string `json:"container,omitempty"` Lease string `json:"lease,omitempty"` // OpStop: Leases is the reply, the leases the daemon asked to drain. + // OpUnpark, OpParkMode: the leases evicted. Leases []string `json:"leases,omitempty"` + // OpParkMode: the parking switch, asked (true = off) and answered. + ParkOff *bool `json:"park_off,omitempty"` // OpCordon, OpUncordon: Cards is the reply, the cards' names. Cards []string `json:"cards,omitempty"` Reason string `json:"reason,omitempty"` @@ -340,6 +352,9 @@ type Status struct { // scope's anon plus shmem, or its VRAM footprint where Berth reads no // scope. A reading, never a reservation. ParkedBytes uint64 `json:"parked_bytes,omitempty"` + // ParkOff is set while berth park off has parking switched off: a + // drain stops its engine instead of parking it in RAM. + ParkOff bool `json:"park_off,omitempty"` // Passthrough is the VM state of the first scheduled card, set only // while every scheduled card in this status is bound to vfio-pci or // switching to or from it, so none serves. With one card of several in diff --git a/internal/server/park.go b/internal/server/park.go index 61c7d0d..9fb84eb 100644 --- a/internal/server/park.go +++ b/internal/server/park.go @@ -27,6 +27,17 @@ func resume(conn *proto.Conn, log *slog.Logger, req proto.Msg, n *node.Node) { } } +// parkMode answers OpParkMode: with ParkOff set it flips the switch (off +// evicts what is parked), without it it only reports. +func parkMode(conn *proto.Conn, log *slog.Logger, req proto.Msg, n *node.Node) { + ids := []string{} + if req.ParkOff != nil { + ids = n.SetParkOff(*req.ParkOff) + } + off := n.ParkOff() + reply(conn, log, proto.Msg{Op: proto.OpParkMode, ParkOff: &off, Leases: ids}) +} + // switched answers OpSwitch: the engine's queued lease is now a person's // switch. func switched(conn *proto.Conn, log *slog.Logger, req proto.Msg, n *node.Node) { diff --git a/internal/server/server.go b/internal/server/server.go index cb8625e..d2d88fd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -192,6 +192,10 @@ func handle(ctx context.Context, c net.Conn, n *node.Node, x Extras, log *slog.L pairing(conn, log, first, x.Pair) case proto.OpResume: resume(conn, log, first, n) + case proto.OpUnpark: + reply(conn, log, proto.Msg{Op: proto.OpUnpark, Engine: first.Engine, Leases: n.Unpark(first.Engine)}) + case proto.OpParkMode: + parkMode(conn, log, first, n) case proto.OpSwitch: switched(conn, log, first, n) case proto.OpEvents: From 9f77e07ab07168e5e84edabb3415cd2d33a5b41b Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:53:10 -0700 Subject: [PATCH 2/2] feat(cli): berth status says when parking is off The human status gains a park line while berth park off is set, and the agent skill gains a row for berth unpark and berth park. --- internal/cli/status.go | 3 +++ internal/cli/unpark_test.go | 3 +++ internal/skill/SKILL.md | 1 + 3 files changed, 7 insertions(+) diff --git a/internal/cli/status.go b/internal/cli/status.go index 7a1aa3b..68cebaf 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -70,6 +70,9 @@ func (v statusView) Human(c *Context, ok bool) error { if st.ParkedBytes > 0 { c.Printf("warm parked engines hold %s of RAM\n", server.MiB(st.ParkedBytes)) } + if st.ParkOff { + c.Printf("park off: a drain stops its engine (berth park on turns parking back on)\n") + } if c.Verbose { hostLine(c, st.Host) alertsLine(c, st.Alerts) diff --git a/internal/cli/unpark_test.go b/internal/cli/unpark_test.go index 03e1d10..8194c0e 100644 --- a/internal/cli/unpark_test.go +++ b/internal/cli/unpark_test.go @@ -40,6 +40,9 @@ func TestParkSwitchRoundTrip(t *testing.T) { if code != ExitOK || !strings.Contains(out, "parking is off") || !b.ParkOff() { t.Fatalf("park off: exit %d out %q err %q", code, out, errOut) } + if _, out, _ = runCLI(t, "status", "--socket", sock); !strings.Contains(out, "park off") { + t.Fatalf("status while parking is off: %q", out) + } code, out, _ = runCLI(t, "park", "--socket", sock, "--format", "json") var v struct { Parking string `json:"parking"` diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index cc07665..0877d22 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -57,6 +57,7 @@ do not assume any engine is protected. | Close a card to new work while the others serve | `berth cordon gpu1` (holders keep running; `berth uncordon gpu1` reopens it) | | Stop an engine Berth started | `berth stop ENGINE [--wait]` (normal drain, parked engines included; engines outlive a gateway restart) | | Bring a parked engine back | `berth resume ENGINE` (or just call its model through the gateway) | +| Free the RAM a parked engine holds, before a RAM-heavy job | `berth unpark ENGINE` or `berth unpark --all` (nothing parked is no error); `berth park off` stops parking until `berth park on` | | Read a model's file ahead into RAM | `berth prefetch MODEL` (page cache only; nothing is reserved) | | Run the node and gateway at login | `berth service install [--gateway] [--harbor]` (`--dry-run` first) |