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
- Variables
- func BucketConfig(session string) *nats.KeyValueConfig
- func BucketName(session string) string
- func ServeQueries(nc *nats.Conn, r *Registry, reconciledAt func() int64) (*nats.Subscription, error)
- func ServeUpdates(nc *nats.Conn, r *Registry, onChange func(op string, f *Fleet, name string)) (*nats.Subscription, error)
- func ValidateName(name string) error
- type Fleet
- type KV
- type LivePanesFunc
- type Member
- type Persistence
- type Query
- type Registry
- func (r *Registry) FleetOfPane(paneID string) string
- func (r *Registry) Forget(name string) bool
- func (r *Registry) Get(name string) *Fleet
- func (r *Registry) Len() int
- func (r *Registry) List() []Fleet
- func (r *Registry) Reconcile(livePaneIDs map[string]bool) int
- func (r *Registry) Register(f Fleet) (*Fleet, error)
- func (r *Registry) RemoveMember(name, paneID string) bool
- func (r *Registry) SetState(name, state string) error
- func (r *Registry) UpsertMember(name string, m Member) error
- type Reply
- type Update
- type UpdateReply
Constants ¶
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.
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.
const ( OpRegister = "register" OpForget = "forget" OpState = "state" OpMemberUpsert = "member-upsert" OpMemberRemove = "member-remove" )
Update operations.
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 ¶
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 ¶
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 ¶
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.
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 ¶
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 (*Registry) FleetOfPane ¶
FleetOfPane reports which fleet a pane belongs to, or "".
func (*Registry) Forget ¶
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 ¶
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) List ¶
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 ¶
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 ¶
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 ¶
RemoveMember drops one member by pane id.
func (*Registry) UpsertMember ¶
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.
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 ¶
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.