Help us shape the future of Go! Take the 2026 Go Developer Survey

fleet

package
v0.2.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 16, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

Documentation

Overview

Package fleet is the session's registry of agent fleets (design 028 §6.5): which fleets exist, who is in them, and which board each one talks on.

THE PACKAGE BOUNDARY IS THE POINT, and it is the same one internal/board draws. This package must not import internal/actors or internal/tui, and it does no NATS, no I/O and no clock of its own. What it holds is a MODEL — a registry that can be tested exhaustively without standing anything up — while the actor around it owns subscriptions, persistence and the question of who is alive.

WHY THIS EXISTS AT ALL, since the fleet layer is a Python script and worked without it. The registry was `.rysh/fleet/<name>.json`: untracked, shared by every fleet in the workspace, and written by whichever `fleetctl` happened to run. Two failures are on record from live sessions — a sibling fleet's teardown DELETED a live fleet's manifest, taking the tool down for everyone; and `fleetctl worktree` reported `created: true` for a directory that did not exist. Both are registry-truth problems, and both get linearly worse with the number of fleets. The manifest stays as a cache; this is the authority.

Index

Constants

View Source
const (
	// RetentionTTL is how long one fleet record lives without being rewritten.
	RetentionTTL = 90 * 24 * time.Hour
	// RetentionMaxBytes bounds the bucket on disk.
	RetentionMaxBytes = 8 << 20
)

Durability: the registry SURVIVES A DAEMON RESTART, and that is most of the reason it moved out of `.rysh/fleet/*.json` (design 028 §6.5).

One KV entry per fleet, keyed by name. Unlike the board's history there is no ordinal and no ordering to preserve — a fleet is a record that is overwritten in place, so a second writer here costs a lost UPDATE rather than deleted history. That is a real difference and it is why this file is short: the board's whole single-writer apparatus exists to protect an append-only sequence, and this is not one.

RETENTION IS DELIBERATELY LONGER THAN THE BOARD'S. Board history is monitoring and ages out in a week; a fleet's identity is what lets a human resume `claude -r <session_id>` in a worktree weeks later, and losing it turns a set of live agents into anonymous panes. It is bounded rather than unbounded because a session that registers and forgets fleets forever should still not grow without limit.

View Source
const (
	StateRegistered = "registered"
	StateUp         = "up"
	StateDown       = "down"
)

State is where a fleet is in its lifecycle.

REGISTERED IS NOT A LESSER FORM OF UP, it is the cheap half of the split that makes 25 fleets affordable: a registered fleet is a row, a board id and a roadmap — no panes, no claude sessions, no spend. Bringing it up is what costs, which is what the concurrency cap in design 028 §6.8 (`D-14`) bounds.

View Source
const (
	OpRegister     = "register"
	OpForget       = "forget"
	OpState        = "state"
	OpMemberUpsert = "member-upsert"
	OpMemberRemove = "member-remove"
)

Update operations.

View Source
const MaxUp = 3

MaxUp is how many fleets may be `up` at once (founder gate `D-14`, ruled 3 on 2026-08-11).

THE CAP IS ENFORCED HERE, NOT IN THE DRIVER, and that is the point of the number living in this file. A cap the driver respects is a cap that holds until somebody runs `fleetctl up` twice by hand, or a second driver starts, or a script loops — and the failure it prevents is not tidiness: twenty-five fleets is roughly 150 panes and 125 live claude sessions, against a pane limit that refuses first and load failures that report success they did not have (design 028 §6.8).

REGISTERED IS FREE. The cap counts only StateUp, so a driver may register all twenty-five and promote them three at a time.

Variables

View Source
var ErrNoRegistry = errors.New("fleet: the registry did not answer")

ErrNoRegistry means nobody answered. Callers MUST branch on it.

Functions

func BucketConfig

func BucketConfig(session string) *nats.KeyValueConfig

BucketConfig is the bucket the bus should create. It lives here, beside the retention constants, so the numbers and the thing they configure cannot drift apart.

func BucketName

func BucketName(session string) string

BucketName is the KV bucket for a session's fleet registry, namespaced by session exactly like every other bucket in internal/bus.

func ServeQueries

func ServeQueries(nc *nats.Conn, r *Registry, reconciledAt func() int64) (*nats.Subscription, error)

ServeQueries answers registry reads for as long as the returned subscription lives.

IT ANSWERS FROM THE CACHED REGISTRY AND NEVER CALLS BACK INTO THE WORKSPACE, and that is a correctness requirement rather than a performance choice. The WORKSPACE is a caller here — `##fleet list` runs inside it — and the fleet actor's reconcile asks the workspace for a snapshot. If this handler reconciled on demand, the workspace would block in Receive waiting for an answer that requires the workspace to answer a snapshot first: a deadlock broken only by a timeout, which would surface as "the registry did not answer" on a healthy session. Reconciliation therefore happens on the actor's own schedule, and the answer says how old it is.

func ServeUpdates

func ServeUpdates(nc *nats.Conn, r *Registry, onChange func(op string, f *Fleet, name string)) (*nats.Subscription, error)

ServeUpdates accepts registry writes. onChange is called after every accepted update, so the caller can persist without this file knowing what a KV is.

func ValidateName

func ValidateName(name string) error

ValidateName rejects a fleet name that cannot also be a board id.

ONE NAMESPACE, DELIBERATELY. A fleet's board id defaults to its name (design 028 §6.5), and a board id becomes a NATS subject token — so a fleet called "epic.07" would name a board nobody can subscribe to. Rejecting at registration is the only place this can be caught before the fleet exists; afterwards the failure is a board that is silently empty.

Types

type Fleet

type Fleet struct {
	Name       string `json:"name"`
	BoardID    string `json:"board_id"`
	Tab        string `json:"tab,omitempty"`
	Source     string `json:"source,omitempty"` // the epic doc it was cut from
	RoadmapDir string `json:"roadmap_dir,omitempty"`
	State      string `json:"state"`
	Created    int64  `json:"created,omitempty"` // unix millis, stamped by the caller

	Members []Member `json:"members"`
}

Fleet is one fleet: its identity, where its work is described, and who is in it.

func Normalise

func Normalise(f Fleet) Fleet

Normalise fills in what a caller may leave blank and returns the fleet it will be stored as. It does not validate; Register does.

type KV

type KV interface {
	Put(key string, value []byte) (uint64, error)
	Get(key string) (nats.KeyValueEntry, error)
	Delete(key string, opts ...nats.DeleteOpt) error
	Keys(opts ...nats.WatchOpt) ([]string, error)
}

KV is the slice of nats.KeyValue this package uses. Narrow on purpose: it keeps internal/fleet out of the bus's dependency tree and makes the store testable against a fake as well as against a real JetStream.

type LivePanesFunc

type LivePanesFunc func() (map[string]bool, error)

LivePanesFunc reports the panes that exist in the session right now.

THE ERROR MUST NOT BE SWALLOWED INTO AN EMPTY MAP. "I could not ask" and "I asked and nobody is there" are different facts and only one of them licenses dropping members — Registry.Reconcile treats the empty set as "caller does not know" for exactly this reason.

type Member

type Member struct {
	PaneID    string `json:"pane_id"`
	Label     string `json:"label,omitempty"`
	Role      string `json:"role,omitempty"`
	Unit      string `json:"unit,omitempty"`
	Parent    string `json:"parent,omitempty"` // parent's pane id
	Worktree  string `json:"worktree,omitempty"`
	SessionID string `json:"session_id,omitempty"` // resumable with `claude -r`
}

Member is one agent in a fleet.

PaneID is the identity and the only field this package treats as unique — the same rule the board applies to posts, and for the same reason: a label is a given-name, given-names are unique per LANE rather than per session (TabActor.IsGivenNameTakenInLane), and two fleets in different lanes may legally hold the same one.

type Persistence

type Persistence struct {
	// contains filtered or unexported fields
}

Persistence writes fleet records to a KV and replays them on restart.

The nil *Persistence is usable and does nothing — that is what a session without JetStream gets, and it degrades to a registry that forgets on restart rather than a session that refuses to start.

func NewPersistence

func NewPersistence(kv KV) *Persistence

NewPersistence returns a Persistence over kv. A nil kv yields a nil Persistence, whose methods are no-ops.

func (*Persistence) Delete

func (p *Persistence) Delete(name string) error

Delete removes one fleet record. A missing key is not an error: forgetting a fleet that was never persisted is the same outcome the caller asked for.

func (*Persistence) Restore

func (p *Persistence) Restore(r *Registry) (int, error)

Restore replays every persisted fleet into r and returns how many were applied.

A record that fails to fetch or decode is SKIPPED, not fatal, and the count returned is what really loaded — the same rule the board's restore follows. A registry that refuses to start because one fleet's record went bad is worse than one missing a fleet, because it takes every other fleet down with it.

func (*Persistence) Save

func (p *Persistence) Save(f *Fleet) error

Save writes one fleet record, overwriting the previous one.

type Query

type Query struct {
	// Name selects one fleet. Empty means every fleet.
	Name string `json:"name,omitempty"`
}

Query asks for one fleet, or for all of them.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is the set of fleets in one session. Safe for concurrent use: the actor's subscription goroutines read it while its reconcile loop writes.

func New

func New() *Registry

New returns an empty Registry.

func (*Registry) FleetOfPane

func (r *Registry) FleetOfPane(paneID string) string

FleetOfPane reports which fleet a pane belongs to, or "".

func (*Registry) Forget

func (r *Registry) Forget(name string) bool

Forget removes a fleet from the registry and reports whether it was there.

IT DOES NOT TOUCH PANES. Forgetting is a registry operation; the panes of a forgotten fleet keep running, which is exactly what happened live when a sibling's teardown deleted a manifest — the agents carried on while the tool that could address them lost their names. Naming that here is the point: callers that want the agents stopped must stop them, and this is not that.

func (*Registry) Get

func (r *Registry) Get(name string) *Fleet

Get returns a copy of one fleet, or nil.

A COPY, so a caller cannot mutate the registry by holding a pointer it was handed for reading. The board store makes the opposite trade and documents it; here the objects are small and the readers are many (a CLI, a ## verb, the interrupt path), so copying is cheaper than the discipline.

func (*Registry) Len

func (r *Registry) Len() int

Len is how many fleets the registry holds.

func (*Registry) List

func (r *Registry) List() []Fleet

List returns every fleet, ordered by name so a listing is stable. Go randomises map iteration, and a fleet list that reorders itself between two calls looks like the session changed.

func (*Registry) Reconcile

func (r *Registry) Reconcile(livePaneIDs map[string]bool) int

Reconcile drops members whose panes no longer exist and reports how many were dropped.

THE EMPTY SET MEANS "THE CALLER DOES NOT KNOW" and retains everything. This is board.Store.RetainRoster's rule, restated because the failure it prevents is worse here: a momentarily empty or failed snapshot would empty every fleet's roster, and a registry that reports a fleet as having no members is indistinguishable from a fleet that finished. A registry that overcounts is bad; one that silently empties itself is unrecoverable.

It never removes the FLEET, only members. A fleet whose panes have all gone is a fleet that is down — which is a state, decided by whoever owns the lifecycle, not something to infer from a snapshot.

func (*Registry) Register

func (r *Registry) Register(f Fleet) (*Fleet, error)

Register adds or REPLACES a fleet's identity, keeping its members.

Members are kept rather than replaced because registration and membership arrive from different places at different times: `fleetctl up` registers the fleet, then adds each agent as its pane is created. A Register that emptied the roster would make a re-registration — which happens on every restart of the driver — silently forget everyone who was already there.

func (*Registry) RemoveMember

func (r *Registry) RemoveMember(name, paneID string) bool

RemoveMember drops one member by pane id.

func (*Registry) SetState

func (r *Registry) SetState(name, state string) error

SetState moves a fleet through its lifecycle.

func (*Registry) UpsertMember

func (r *Registry) UpsertMember(name string, m Member) error

UpsertMember adds a member or updates the one with the same pane id.

Keyed on PaneID and never on Label: a fleet legitimately holds two agents called `wkr-01` in different lanes, and keying on the label would silently merge them into one — which reads as a fleet half its real size.

type Reply

type Reply struct {
	Fleets []Fleet `json:"fleets"`

	// ReconciledAt is when membership was last checked against the panes that
	// actually exist, in unix millis; 0 means never.
	//
	// IT IS REPORTED RATHER THAN HIDDEN because the answer is served from the
	// actor's cache and is therefore as old as the last reconcile. That is a
	// deliberate design constraint, not an oversight — see ServeQueries — and a
	// caller that cannot tell a fresh roster from a stale one would eventually
	// act on a member that closed ten minutes ago. Same discipline as the
	// board's RosterReconciled.
	ReconciledAt int64 `json:"reconciled_at,omitempty"`

	// Err carries a refusal. A registry that cannot answer says so; it never
	// answers with an empty list.
	Err string `json:"err,omitempty"`
}

Reply is what the fleet actor answers.

func Ask

func Ask(nc *nats.Conn, q Query, timeout time.Duration) (*Reply, error)

Ask puts a Query to the registry and returns its answer.

It returns (nil, ErrNoRegistry) when nobody answers — no responder, a timeout, or no connection — and NEVER a usable-looking empty reply alongside an error.

type Update

type Update struct {
	Op string `json:"op"`

	Fleet  Fleet  `json:"fleet,omitempty"`  // OpRegister
	Name   string `json:"name,omitempty"`   // every other op
	State  string `json:"state,omitempty"`  // OpState
	Member Member `json:"member,omitempty"` // OpMemberUpsert
	PaneID string `json:"pane_id,omitempty"`
}

Update is a write against the registry.

type UpdateReply

type UpdateReply struct {
	OK    bool   `json:"ok"`
	Fleet *Fleet `json:"fleet,omitempty"`
	Err   string `json:"err,omitempty"`
}

UpdateReply reports what the registry did.

func Send

func Send(nc *nats.Conn, u Update, timeout time.Duration) (*UpdateReply, error)

Send puts an Update to the registry.

A refusal from the registry comes back as an error, so a caller cannot mistake "recorded" for "sent and ignored" — `fleetctl` puts agents into a fleet immediately after registering it, and a registration that quietly failed would produce a fleet whose members belong to nothing.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL